From 6ac50c4d58ed4be575f980b8d39cda78cd484683 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 00:56:16 -0400 Subject: [PATCH] Harden the static file server against path traversal (security review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/server/src/index.ts | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f8b11cb..4d2aea0 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -12,8 +12,8 @@ // {type:"error", message} import { createServer } from "node:http"; -import { readFileSync, existsSync } from "node:fs"; -import { extname, join, normalize } from "node:path"; +import { readFileSync, existsSync, realpathSync } from "node:fs"; +import { extname, join, normalize, sep } from "node:path"; import { WebSocketServer, WebSocket } from "ws"; import type { Command, PlayerId } from "@wizwar/engine"; import { @@ -42,17 +42,34 @@ const MIME: Record = { ".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".woff2": "font/woff2", ".json": "application/json", }; +const staticRoot = realpathSync(normalize(STATIC_DIR)); const httpServer = createServer((req, res) => { try { - const url = (req.url ?? "/").split("?")[0]!; - let file = normalize(join(STATIC_DIR, url === "/" ? "index.html" : url)); - if (!file.startsWith(normalize(STATIC_DIR))) { + let url: string; + try { + 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(); return; } - if (!existsSync(file)) file = join(STATIC_DIR, "index.html"); // SPA fallback - const body = readFileSync(file); - res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" }); + if (!existsSync(file)) file = join(staticRoot, "index.html"); // SPA fallback + // Resolve symlinks and re-verify the real location stays inside the root. + 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); } catch { res.writeHead(500).end();