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
This commit is contained in:
pablolozano0216-art
2026-09-21 14:32:21 -04:00
committed by GitHub
parent 2b6e839771
commit 8d59727633
+20 -5
View File
@@ -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}`);