From 03a7b9c0666fbe068dd07d39f90a2bf3fafd49d7 Mon Sep 17 00:00:00 2001 From: Xiangyu Han Date: Sat, 21 Mar 2026 14:40:47 +0800 Subject: [PATCH] fix(backend): prevent static file path traversal --- backend/src/index.ts | 20 ++++++++++++-------- backend/src/utils/staticFile.test.ts | 28 ++++++++++++++++++++++++++++ backend/src/utils/staticFile.ts | 17 +++++++++++++++++ 3 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 backend/src/utils/staticFile.test.ts create mode 100644 backend/src/utils/staticFile.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 347e359..0acdd22 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -27,6 +27,7 @@ import { PRODUCTION, } from "@/utils/config"; import { initConfig } from "./utils/init"; +import { resolveStaticPath } from "@/utils/staticFile"; await initConfig(); @@ -62,7 +63,12 @@ function getMimeType(filePath: string): string { return mimeTypes[ext || ""] || "application/octet-stream"; } -async function serveStaticFile(filePath: string) { +async function serveStaticFile(rootDir: string, requestPath: string) { + const filePath = resolveStaticPath(rootDir, requestPath); + if (!filePath) { + return null; + } + try { const fileStat = await stat(filePath); if (fileStat.isFile()) { @@ -91,7 +97,7 @@ async function docsPlugin(dir: string) { // Handle TanStack Start's __tsr static server function cache requests // These are requested from root path, not /docs/ .get("/__tsr/*", async ({ path, status }) => { - const response = await serveStaticFile(join(dir, path)); + const response = await serveStaticFile(dir, path); return response || status(404); }) .get("/docs", ({ status }) => { @@ -107,17 +113,15 @@ async function docsPlugin(dir: string) { return status(404); } const subPath = path.replace("/docs", ""); - const exactPath = join(dir, subPath); - // Try to serve as static file - const staticResponse = await serveStaticFile(exactPath); + const staticResponse = await serveStaticFile(dir, subPath); if (staticResponse) { return staticResponse; } // Check if there's a specific HTML file for this path (directory with index.html) - const specificHtmlPath = join(dir, subPath, "index.html"); - if (await exists(specificHtmlPath)) { + const specificHtmlPath = resolveStaticPath(dir, `${subPath}/index.html`); + if (specificHtmlPath && (await exists(specificHtmlPath))) { const html = await readFile(specificHtmlPath, "utf-8"); return new Response(html, { headers: { "Content-Type": "text/html" }, @@ -166,7 +170,7 @@ async function spaPlugin(dir: string) { } // Try to serve as static file first - const staticResponse = await serveStaticFile(join(dir, path)); + const staticResponse = await serveStaticFile(dir, path); if (staticResponse) { return staticResponse; } diff --git a/backend/src/utils/staticFile.test.ts b/backend/src/utils/staticFile.test.ts new file mode 100644 index 0000000..bda4ac6 --- /dev/null +++ b/backend/src/utils/staticFile.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { resolveStaticPath } from "./staticFile"; + +describe("resolveStaticPath", () => { + test("resolves request paths inside the static root", () => { + expect(resolveStaticPath("/app/public", "/assets/main.js")).toBe( + "/app/public/assets/main.js", + ); + }); + + test("allows the static root itself", () => { + expect(resolveStaticPath("/app/public", "/")).toBe("/app/public"); + }); + + test("blocks parent-directory traversal", () => { + expect(resolveStaticPath("/app/public", "/../../etc/passwd")).toBeNull(); + }); + + test("blocks traversal without a leading slash", () => { + expect(resolveStaticPath("/app/public", "../../etc/passwd")).toBeNull(); + }); + + test("normalizes dot segments that stay inside the root", () => { + expect(resolveStaticPath("/app/public", "/assets/../index.html")).toBe( + "/app/public/index.html", + ); + }); +}); diff --git a/backend/src/utils/staticFile.ts b/backend/src/utils/staticFile.ts new file mode 100644 index 0000000..6be29c6 --- /dev/null +++ b/backend/src/utils/staticFile.ts @@ -0,0 +1,17 @@ +import { isAbsolute, relative, resolve } from "node:path"; + +export function resolveStaticPath( + rootDir: string, + requestPath: string, +): string | null { + const resolvedRoot = resolve(rootDir); + const normalizedRequestPath = requestPath.replace(/^\/+/u, ""); + const resolvedPath = resolve(resolvedRoot, normalizedRequestPath); + const relativePath = relative(resolvedRoot, resolvedPath); + + if (relativePath.startsWith("..") || isAbsolute(relativePath)) { + return null; + } + + return resolvedPath; +}