diff --git a/backend/src/index.ts b/backend/src/index.ts index 347e359..9bde02a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,6 +15,7 @@ async function exists(path: string): Promise { } } import { join } from "node:path"; +import { resolvePathWithinBase } from "@/utils/safePath"; import { routes } from "@/api"; import { metricsApi } from "@/api/metrics"; import { loggerPlugin } from "@/plugins/loggerPlugin"; @@ -91,7 +92,12 @@ 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 filePath = resolvePathWithinBase(dir, path); + if (!filePath) { + return status(404); + } + + const response = await serveStaticFile(filePath); return response || status(404); }) .get("/docs", ({ status }) => { @@ -107,7 +113,10 @@ async function docsPlugin(dir: string) { return status(404); } const subPath = path.replace("/docs", ""); - const exactPath = join(dir, subPath); + const exactPath = resolvePathWithinBase(dir, subPath); + if (!exactPath) { + return status(404); + } // Try to serve as static file const staticResponse = await serveStaticFile(exactPath); @@ -116,8 +125,11 @@ async function docsPlugin(dir: string) { } // 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 = resolvePathWithinBase( + dir, + join(subPath, "index.html"), + ); + if (specificHtmlPath && (await exists(specificHtmlPath))) { const html = await readFile(specificHtmlPath, "utf-8"); return new Response(html, { headers: { "Content-Type": "text/html" }, diff --git a/backend/src/utils/safePath.test.ts b/backend/src/utils/safePath.test.ts new file mode 100644 index 0000000..2321de1 --- /dev/null +++ b/backend/src/utils/safePath.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { resolvePathWithinBase } from "./safePath"; + +describe("resolvePathWithinBase", () => { + const baseDir = resolve("/tmp/docs-base"); + + test("resolves nested relative paths inside the base directory", () => { + expect(resolvePathWithinBase(baseDir, "/assets/app.js")).toBe( + resolve(baseDir, "assets/app.js"), + ); + }); + + test("keeps base directory requests inside the base directory", () => { + expect(resolvePathWithinBase(baseDir, "/")).toBe(baseDir); + }); + + test("rejects traversal outside the base directory", () => { + expect(resolvePathWithinBase(baseDir, "/../../etc/passwd")).toBeNull(); + }); + + test("contains repeated leading slashes within the base directory", () => { + expect(resolvePathWithinBase(baseDir, "//etc/passwd")).toBe( + resolve(baseDir, "etc/passwd"), + ); + }); +}); diff --git a/backend/src/utils/safePath.ts b/backend/src/utils/safePath.ts new file mode 100644 index 0000000..5b9127a --- /dev/null +++ b/backend/src/utils/safePath.ts @@ -0,0 +1,18 @@ +import { resolve } from "node:path"; + +export function resolvePathWithinBase( + baseDir: string, + requestPath: string, +): string | null { + const normalizedRequestPath = requestPath.replaceAll("\\", "/"); + const pathSegments = normalizedRequestPath + .replace(/^\/+/, "") + .split("/") + .filter(Boolean); + + if (pathSegments.includes("..")) { + return null; + } + + return resolve(baseDir, ...pathSegments); +}