From 307f325059e3b4576755462ee335f60860d853d7 Mon Sep 17 00:00:00 2001 From: JulesB40 Date: Mon, 23 Feb 2026 03:51:37 +0100 Subject: [PATCH] fix(security): prevent path traversal in /assets/ endpoint The /assets/ endpoint was vulnerable to path traversal attacks that could allow reading arbitrary files on the server (e.g., /assets/../.env, /assets/../server.ts). This fix resolves the path and validates that it remains within the public directory before serving the file. Returns 403 Forbidden if path escapes the intended directory. --- server.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/server.ts b/server.ts index 7f26f0f..53c8e19 100644 --- a/server.ts +++ b/server.ts @@ -1,5 +1,6 @@ import type { ServerWebSocket } from "bun"; import { timingSafeEqual } from "node:crypto"; +import { resolve, relative } from "node:path"; import indexHtml from "./index.html"; import historyHtml from "./history.html"; import adminHtml from "./admin.html"; @@ -115,6 +116,7 @@ const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt( ); const ADMIN_COOKIE = "quipslop_admin"; const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; +const PUBLIC_DIR = resolve("./public"); const requestWindows = new Map(); const wsByIp = new Map(); @@ -423,8 +425,16 @@ const server = Bun.serve({ const ip = getClientIp(req, server); if (url.pathname.startsWith("/assets/")) { - const path = `./public${url.pathname}`; - const file = Bun.file(path); + const assetPath = url.pathname.slice(8); + if (!assetPath) { + return new Response("Not found", { status: 404 }); + } + const resolved = resolve(PUBLIC_DIR, assetPath); + const relativePath = relative(PUBLIC_DIR, resolved); + if (relativePath.startsWith("..") || relativePath === "..") { + return new Response("Forbidden", { status: 403 }); + } + const file = Bun.file(resolved); return new Response(file, { headers: { "Cache-Control": "public, max-age=604800, immutable",