From 8d597276331cde4e1e198744eac7157b27a556d0 Mon Sep 17 00:00:00 2001 From: pablolozano0216-art Date: Mon, 21 Sep 2026 13:32:21 -0500 Subject: [PATCH] fix(skills): confine frontend-slides export server to the deck directory and bind loopback (#3206) The temporary HTTP server that export-pdf.sh spins up to render a deck joined the decoded request path onto SERVE_DIR without checking the resolved path, and listened on all interfaces. A request such as `/..%2f..%2f..%2fetc%2fpasswd` (raw, or issued by a malicious deck via fetch() while it renders) returned files outside the deck directory, and any host on the network could hit the port while an export was running. - resolve the decoded path against SERVE_ROOT and answer 403 when the result is not inside it (handles ../, %2e%2e, %2f and query strings) - answer 400 on malformed percent-encoding instead of throwing - listen on 127.0.0.1 only; the only client is the local headless browser Verified with raw HTTP requests against the handler: `/` and `/index.html` -> 200, `/../x`, `/..%2f..%2fx`, `/%2e%2e/x`, `/deck/../../x` -> 403, unknown file -> 404, `/%zz` -> 400, and server.address() reports 127.0.0.1. Fixes #3101 --- skills/frontend-slides/scripts/export-pdf.sh | 25 ++++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/skills/frontend-slides/scripts/export-pdf.sh b/skills/frontend-slides/scripts/export-pdf.sh index ee446b793..2ab289f89 100755 --- a/skills/frontend-slides/scripts/export-pdf.sh +++ b/skills/frontend-slides/scripts/export-pdf.sh @@ -136,7 +136,7 @@ cat > "$TEMP_SCRIPT" << 'EXPORT_SCRIPT' import { chromium } from 'playwright'; import { createServer } from 'http'; import { readFileSync, existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs'; -import { join, extname, resolve } from 'path'; +import { join, extname, resolve, sep } from 'path'; import { execSync } from 'child_process'; const SERVE_DIR = process.argv[2]; @@ -166,10 +166,25 @@ const MIME_TYPES = { '.eot': 'application/vnd.ms-fontobject', }; +// Every request is confined to the deck directory: resolve the decoded path +// against SERVE_ROOT and refuse anything that escapes it (../, %2e%2e, %2f). +const SERVE_ROOT = resolve(SERVE_DIR); const server = createServer((req, res) => { // Decode URL-encoded characters (e.g., %20 -> space) so filenames with spaces resolve correctly - const decodedUrl = decodeURIComponent(req.url); - let filePath = join(SERVE_DIR, decodedUrl === '/' ? HTML_FILE : decodedUrl); + let decodedUrl; + try { + decodedUrl = decodeURIComponent((req.url || '/').split('?')[0]); + } catch { + res.writeHead(400); + res.end('Bad request'); + return; + } + const filePath = resolve(SERVE_ROOT, '.' + (decodedUrl === '/' ? '/' + HTML_FILE : decodedUrl)); + if (filePath !== SERVE_ROOT && !filePath.startsWith(SERVE_ROOT + sep)) { + res.writeHead(403); + res.end('Forbidden'); + return; + } try { const content = readFileSync(filePath); const ext = extname(filePath).toLowerCase(); @@ -181,9 +196,9 @@ const server = createServer((req, res) => { } }); -// Find a free port +// Find a free port on loopback only; the deck is rendered by the local headless browser const port = await new Promise((resolve) => { - server.listen(0, () => resolve(server.address().port)); + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); }); console.log(` Local server on port ${port}`);