feat: Implement secure static file serving with path traversal protection and XSS prevention for template titles.
This commit is contained in:
@@ -7,7 +7,7 @@ describe("Database Indexes", () => {
|
|||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'users'
|
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_balance_idx");
|
||||||
expect(indexNames).toContain("users_level_xp_idx");
|
expect(indexNames).toContain("users_level_xp_idx");
|
||||||
});
|
});
|
||||||
@@ -17,7 +17,7 @@ describe("Database Indexes", () => {
|
|||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'transactions'
|
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");
|
expect(indexNames).toContain("transactions_created_at_idx");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ describe("Database Indexes", () => {
|
|||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'moderation_cases'
|
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_user_id_idx");
|
||||||
expect(indexNames).toContain("moderation_cases_case_id_idx");
|
expect(indexNames).toContain("moderation_cases_case_id_idx");
|
||||||
});
|
});
|
||||||
@@ -36,7 +36,7 @@ describe("Database Indexes", () => {
|
|||||||
SELECT indexname FROM pg_indexes
|
SELECT indexname FROM pg_indexes
|
||||||
WHERE tablename = 'user_timers'
|
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_expires_at_idx");
|
||||||
expect(indexNames).toContain("user_timers_lookup_idx");
|
expect(indexNames).toContain("user_timers_lookup_idx");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,31 @@ describe("Web Router", () => {
|
|||||||
expect(data).toHaveProperty("status", "ok");
|
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 () => {
|
it("should return 404 for unknown routes", async () => {
|
||||||
const req = new Request("http://localhost/unknown");
|
const req = new Request("http://localhost/unknown");
|
||||||
const res = await router(req);
|
const res = await router(req);
|
||||||
|
|||||||
@@ -1,26 +1,46 @@
|
|||||||
import { homeRoute } from "./routes/home";
|
import { homeRoute } from "./routes/home";
|
||||||
import { healthRoute } from "./routes/health";
|
import { healthRoute } from "./routes/health";
|
||||||
import { file } from "bun";
|
import { file } from "bun";
|
||||||
import { join } from "path";
|
import { join, resolve } from "path";
|
||||||
|
|
||||||
export async function router(request: Request): Promise<Response> {
|
export async function router(request: Request): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const method = request.method;
|
const method = request.method;
|
||||||
|
|
||||||
// Serve static files from src/web/public
|
// Resolve the absolute path to the public directory
|
||||||
// We treat any path with a dot or starting with specific assets paths as static file candidates
|
const publicDir = resolve(import.meta.dir, "public");
|
||||||
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 (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()) {
|
if (await staticFile.exists()) {
|
||||||
return new Response(staticFile);
|
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 (method === "GET") {
|
|
||||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
return homeRoute();
|
return homeRoute();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { router } from "./router";
|
|||||||
import type { Server } from "bun";
|
import type { Server } from "bun";
|
||||||
|
|
||||||
export class WebServer {
|
export class WebServer {
|
||||||
private static server: Server<any> | null = null;
|
private static server: Server<unknown> | null = null;
|
||||||
|
|
||||||
public static start() {
|
public static start() {
|
||||||
this.server = Bun.serve({
|
this.server = Bun.serve({
|
||||||
|
|||||||
17
src/web/utils/html.test.ts
Normal file
17
src/web/utils/html.test.ts
Normal 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("<script>alert("xss")</script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed content", () => {
|
||||||
|
const unsafe = 'Hello & "World"';
|
||||||
|
const safe = escapeHtml(unsafe);
|
||||||
|
expect(safe).toBe("Hello & "World"");
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/web/utils/html.ts
Normal file
14
src/web/utils/html.ts
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import { escapeHtml } from "../utils/html";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BaseLayout({ title, content }: LayoutProps): string {
|
export function BaseLayout({ title, content }: LayoutProps): string {
|
||||||
|
const safeTitle = escapeHtml(title);
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<link rel="stylesheet" href="/style.css">
|
||||||
<meta name="description" content="Aurora Bot Web Interface">
|
<meta name="description" content="Aurora Bot Web Interface">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user