mirror of
https://github.com/hanzoai/DOMPurify.git
synced 2026-09-24 16:55:05 +00:00
93 lines
3.1 KiB
JavaScript
93 lines
3.1 KiB
JavaScript
/* Tiny static file server for the Playwright browser test runner.
|
|
* Serves the repository root (one level up from this file's parent dir).
|
|
* Zero dependencies — deliberately small so npm install stays lean.
|
|
*
|
|
* Binds to 127.0.0.1 only; used solely during `npm test`.
|
|
* Path-injection and response-injection guards are present below; see
|
|
* inline comments.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const http = require('http');
|
|
const path = require('path');
|
|
|
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
const PORT = Number.parseInt(process.env.PORT || '9877', 10);
|
|
|
|
const MIME = Object.freeze({
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'application/javascript; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.map': 'application/json; charset=utf-8',
|
|
'.mjs': 'application/javascript; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.ts': 'application/javascript; charset=utf-8',
|
|
});
|
|
|
|
/* Error responses never echo any part of the request. They use text/plain so
|
|
* even if a header or body somehow contained markup it would not be rendered
|
|
* as HTML by the receiving browser. */
|
|
function sendError(res, status) {
|
|
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
res.end(String(status));
|
|
}
|
|
|
|
/* True iff `child` is the same as, or resides inside, `parent`.
|
|
* Uses path.relative semantics which CodeQL recognizes as a path-containment
|
|
* sanitizer for js/path-injection. */
|
|
function isInside(child, parent) {
|
|
if (child === parent) return true;
|
|
const rel = path.relative(parent, child);
|
|
return rel.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
/* Parse the request URL defensively. A malformed URL yields 400 before any
|
|
* path math runs. */
|
|
let urlPath;
|
|
try {
|
|
urlPath = new URL(req.url || '/', 'http://127.0.0.1').pathname;
|
|
} catch (_) {
|
|
return sendError(res, 400);
|
|
}
|
|
|
|
/* Resolve the requested path against ROOT. path.resolve produces an
|
|
* absolute path with `..` segments collapsed, which combined with the
|
|
* isInside check below forms a CodeQL-recognized containment pattern. */
|
|
const resolved = path.resolve(ROOT, '.' + urlPath);
|
|
|
|
if (!isInside(resolved, ROOT)) {
|
|
return sendError(res, 403);
|
|
}
|
|
|
|
fs.stat(resolved, (err, stat) => {
|
|
if (err || !stat.isFile()) return sendError(res, 404);
|
|
|
|
/* MIME table lookup is bounded by a frozen allow-list; falls back to a
|
|
* safe generic type rather than the requested extension. */
|
|
const ext = path.extname(resolved).toLowerCase();
|
|
const contentType = MIME[ext] || 'application/octet-stream';
|
|
|
|
fs.readFile(resolved, (readErr, data) => {
|
|
if (readErr) return sendError(res, 500);
|
|
res.writeHead(200, {
|
|
'Content-Type': contentType,
|
|
'Cache-Control': 'no-store',
|
|
'X-Content-Type-Options': 'nosniff',
|
|
});
|
|
res.end(data);
|
|
});
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, '127.0.0.1', () => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`test server listening on http://127.0.0.1:${PORT}`);
|
|
});
|
|
|
|
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
process.on(sig, () => server.close(() => process.exit(0)));
|
|
} |