feat: Implement secure static file serving with path traversal protection and XSS prevention for template titles.

This commit is contained in:
syntaxbullet
2026-01-07 12:51:08 +01:00
parent 2a1c4e65ae
commit 894cad91a8
7 changed files with 98 additions and 19 deletions

View File

@@ -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");
});

View File

@@ -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);

View File

@@ -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<Response> {
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();
}

View File

@@ -3,7 +3,7 @@ import { router } from "./router";
import type { Server } from "bun";
export class WebServer {
private static server: Server<any> | null = null;
private static server: Server<unknown> | null = null;
public static start() {
this.server = Bun.serve({

View File

@@ -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 = '<script>alert("xss")</script>';
const safe = escapeHtml(unsafe);
expect(safe).toBe("&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;");
});
it("should handle mixed content", () => {
const unsafe = 'Hello & "World"';
const safe = escapeHtml(unsafe);
expect(safe).toBe("Hello &amp; &quot;World&quot;");
});
});

14
src/web/utils/html.ts Normal file
View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@@ -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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} | Aurora</title>
<title>${safeTitle} | Aurora</title>
<link rel="stylesheet" href="/style.css">
<meta name="description" content="Aurora Bot Web Interface">
</head>