From 894cad91a8e7fd79205d63ffd211984eda61be0c Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 7 Jan 2026 12:51:08 +0100 Subject: [PATCH] feat: Implement secure static file serving with path traversal protection and XSS prevention for template titles. --- src/db/indexes.test.ts | 8 +++---- src/web/router.test.ts | 25 +++++++++++++++++++++ src/web/router.ts | 46 +++++++++++++++++++++++++++----------- src/web/server.ts | 2 +- src/web/utils/html.test.ts | 17 ++++++++++++++ src/web/utils/html.ts | 14 ++++++++++++ src/web/views/layout.ts | 5 ++++- 7 files changed, 98 insertions(+), 19 deletions(-) create mode 100644 src/web/utils/html.test.ts create mode 100644 src/web/utils/html.ts diff --git a/src/db/indexes.test.ts b/src/db/indexes.test.ts index 1e8f57d..9f61750 100644 --- a/src/db/indexes.test.ts +++ b/src/db/indexes.test.ts @@ -7,7 +7,7 @@ describe("Database Indexes", () => { SELECT indexname FROM pg_indexes WHERE tablename = 'users' `; - const indexNames = result.map((r: any) => r.indexname); + const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname); expect(indexNames).toContain("users_balance_idx"); expect(indexNames).toContain("users_level_xp_idx"); }); @@ -17,7 +17,7 @@ describe("Database Indexes", () => { SELECT indexname FROM pg_indexes WHERE tablename = 'transactions' `; - const indexNames = result.map((r: any) => r.indexname); + const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname); expect(indexNames).toContain("transactions_created_at_idx"); }); @@ -26,7 +26,7 @@ describe("Database Indexes", () => { SELECT indexname FROM pg_indexes WHERE tablename = 'moderation_cases' `; - const indexNames = result.map((r: any) => r.indexname); + const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname); expect(indexNames).toContain("moderation_cases_user_id_idx"); expect(indexNames).toContain("moderation_cases_case_id_idx"); }); @@ -36,7 +36,7 @@ describe("Database Indexes", () => { SELECT indexname FROM pg_indexes WHERE tablename = 'user_timers' `; - const indexNames = result.map((r: any) => r.indexname); + const indexNames = (result as unknown as { indexname: string }[]).map(r => r.indexname); expect(indexNames).toContain("user_timers_expires_at_idx"); expect(indexNames).toContain("user_timers_lookup_idx"); }); diff --git a/src/web/router.test.ts b/src/web/router.test.ts index a086220..39397a6 100644 --- a/src/web/router.test.ts +++ b/src/web/router.test.ts @@ -19,6 +19,31 @@ describe("Web Router", () => { expect(data).toHaveProperty("status", "ok"); }); + it("should block path traversal", async () => { + // Attempts to go up two directories to reach the project root or src + const req = new Request("http://localhost/public/../../package.json"); + const res = await router(req); + // Should be 403 Forbidden or 404 Not Found (our logical change makes it 403) + expect([403, 404]).toContain(res.status); + }); + + it("should serve existing static file", async () => { + // We know style.css exists in src/web/public + const req = new Request("http://localhost/public/style.css"); + const res = await router(req); + expect(res.status).toBe(200); + if (res.status === 200) { + const text = await res.text(); + expect(text).toContain("body"); + } + }); + + it("should not serve static files on non-GET methods", async () => { + const req = new Request("http://localhost/public/style.css", { method: "POST" }); + const res = await router(req); + expect(res.status).toBe(404); + }); + it("should return 404 for unknown routes", async () => { const req = new Request("http://localhost/unknown"); const res = await router(req); diff --git a/src/web/router.ts b/src/web/router.ts index 22a7dff..806e8a9 100644 --- a/src/web/router.ts +++ b/src/web/router.ts @@ -1,26 +1,46 @@ import { homeRoute } from "./routes/home"; import { healthRoute } from "./routes/health"; import { file } from "bun"; -import { join } from "path"; +import { join, resolve } from "path"; export async function router(request: Request): Promise { const url = new URL(request.url); const method = request.method; - // Serve static files from src/web/public - // We treat any path with a dot or starting with specific assets paths as static file candidates - if (url.pathname.includes(".") || url.pathname.startsWith("/public/")) { - // Sanitize path to prevent directory traversal - const safePath = url.pathname.replace(/^(\.\.[\/\\])+/, ''); - const filePath = join(import.meta.dir, "public", safePath); - - const staticFile = file(filePath); - if (await staticFile.exists()) { - return new Response(staticFile); - } - } + // Resolve the absolute path to the public directory + const publicDir = resolve(import.meta.dir, "public"); if (method === "GET") { + // Handle Static Files + // We handle requests starting with /public/ OR containing an extension (like /style.css) + if (url.pathname.startsWith("/public/") || url.pathname.includes(".")) { + // Normalize path: remove /public prefix if present so that + // /public/style.css and /style.css both map to .../public/style.css + const relativePath = url.pathname.replace(/^\/public/, ""); + + // Resolve full path + // We use join with relativePath. If relativePath starts with /, join handles it correctly + // effectively treating it as a segment. + // However, to be extra safe with 'resolve', we ensure we are resolving from publicDir. + // simple join(publicDir, relativePath) is usually enough with 'bun'. + // But we use 'resolve' to handle .. segments correctly. + // We prepend '.' to relativePath to ensure it's treated as relative to publicDir logic + const normalizedRelative = relativePath.startsWith("/") ? "." + relativePath : relativePath; + const requestedPath = resolve(publicDir, normalizedRelative); + + // Security Check: Block Path Traversal + if (requestedPath.startsWith(publicDir)) { + const staticFile = file(requestedPath); + if (await staticFile.exists()) { + return new Response(staticFile); + } + } else { + // If path traversal detected, return 403 or 404. + // 403 indicates we caught them. + return new Response("Forbidden", { status: 403 }); + } + } + if (url.pathname === "/" || url.pathname === "/index.html") { return homeRoute(); } diff --git a/src/web/server.ts b/src/web/server.ts index 83ba295..b22990a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3,7 +3,7 @@ import { router } from "./router"; import type { Server } from "bun"; export class WebServer { - private static server: Server | null = null; + private static server: Server | null = null; public static start() { this.server = Bun.serve({ diff --git a/src/web/utils/html.test.ts b/src/web/utils/html.test.ts new file mode 100644 index 0000000..9da9725 --- /dev/null +++ b/src/web/utils/html.test.ts @@ -0,0 +1,17 @@ + +import { describe, expect, it } from "bun:test"; +import { escapeHtml } from "./html"; + +describe("HTML Utils", () => { + it("should escape special characters", () => { + const unsafe = ''; + const safe = escapeHtml(unsafe); + expect(safe).toBe("<script>alert("xss")</script>"); + }); + + it("should handle mixed content", () => { + const unsafe = 'Hello & "World"'; + const safe = escapeHtml(unsafe); + expect(safe).toBe("Hello & "World""); + }); +}); diff --git a/src/web/utils/html.ts b/src/web/utils/html.ts new file mode 100644 index 0000000..15786ed --- /dev/null +++ b/src/web/utils/html.ts @@ -0,0 +1,14 @@ + +/** + * Escapes unsafe characters in a string to prevent XSS. + * @param unsafe - The raw string to escape. + * @returns The escaped string safe for HTML insertion. + */ +export function escapeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/web/views/layout.ts b/src/web/views/layout.ts index d63ee44..434c60e 100644 --- a/src/web/views/layout.ts +++ b/src/web/views/layout.ts @@ -1,15 +1,18 @@ +import { escapeHtml } from "../utils/html"; + interface LayoutProps { title: string; content: string; } export function BaseLayout({ title, content }: LayoutProps): string { + const safeTitle = escapeHtml(title); return ` - ${title} | Aurora + ${safeTitle} | Aurora