Harden the static file server against path traversal (security review)
The internet-facing static handler now decodes the URL (rejecting bad encodings and null bytes), anchors the containment check with a trailing separator so sibling-prefix directories cannot slip past, and realpath-resolves the final file to defeat symlink escapes — serving only what provably lives inside the built client directory. Deployed and probed live: normal requests 200, literal and percent-encoded traversal attempts both 403. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
23bdd009a0
commit
6ac50c4d58
@@ -12,8 +12,8 @@
|
|||||||
// {type:"error", message}
|
// {type:"error", message}
|
||||||
|
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { readFileSync, existsSync } from "node:fs";
|
import { readFileSync, existsSync, realpathSync } from "node:fs";
|
||||||
import { extname, join, normalize } from "node:path";
|
import { extname, join, normalize, sep } from "node:path";
|
||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
import type { Command, PlayerId } from "@wizwar/engine";
|
import type { Command, PlayerId } from "@wizwar/engine";
|
||||||
import {
|
import {
|
||||||
@@ -42,17 +42,34 @@ const MIME: Record<string, string> = {
|
|||||||
".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon",
|
".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon",
|
||||||
".woff2": "font/woff2", ".json": "application/json",
|
".woff2": "font/woff2", ".json": "application/json",
|
||||||
};
|
};
|
||||||
|
const staticRoot = realpathSync(normalize(STATIC_DIR));
|
||||||
const httpServer = createServer((req, res) => {
|
const httpServer = createServer((req, res) => {
|
||||||
try {
|
try {
|
||||||
const url = (req.url ?? "/").split("?")[0]!;
|
let url: string;
|
||||||
let file = normalize(join(STATIC_DIR, url === "/" ? "index.html" : url));
|
try {
|
||||||
if (!file.startsWith(normalize(STATIC_DIR))) {
|
url = decodeURIComponent((req.url ?? "/").split("?")[0]!);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.includes("\0")) {
|
||||||
|
res.writeHead(400).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
|
||||||
|
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
|
||||||
res.writeHead(403).end();
|
res.writeHead(403).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!existsSync(file)) file = join(STATIC_DIR, "index.html"); // SPA fallback
|
if (!existsSync(file)) file = join(staticRoot, "index.html"); // SPA fallback
|
||||||
const body = readFileSync(file);
|
// Resolve symlinks and re-verify the real location stays inside the root.
|
||||||
res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
|
const real = realpathSync(file);
|
||||||
|
if (real !== staticRoot && !real.startsWith(staticRoot + sep)) {
|
||||||
|
res.writeHead(403).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = readFileSync(real);
|
||||||
|
res.writeHead(200, { "content-type": MIME[extname(real)] ?? "application/octet-stream" });
|
||||||
res.end(body);
|
res.end(body);
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(500).end();
|
res.writeHead(500).end();
|
||||||
|
|||||||
Reference in New Issue
Block a user