From aa94da1f747740f87021899471a6ebf646e0b8d7 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Sun, 5 Jul 2026 01:07:52 +0200 Subject: [PATCH 1/3] fix(dashboard): harden static path traversal checks --- .../issue-monitor/server-handler.mjs | 8 +++++--- .../tests/server.test.mjs | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/apm-contributor-dashboard/.apm/extensions/issue-monitor/server-handler.mjs b/packages/apm-contributor-dashboard/.apm/extensions/issue-monitor/server-handler.mjs index a152b7430..cf49a70a4 100644 --- a/packages/apm-contributor-dashboard/.apm/extensions/issue-monitor/server-handler.mjs +++ b/packages/apm-contributor-dashboard/.apm/extensions/issue-monitor/server-handler.mjs @@ -7,7 +7,7 @@ // const server = createServer(handler); import { readFileSync } from "node:fs"; -import { join, resolve, normalize } from "node:path"; +import { isAbsolute, join, normalize, relative, resolve } from "node:path"; import { randomBytes } from "node:crypto"; import { parsePanelReview, extractFollowUpItems } from "./logic.mjs"; @@ -456,8 +456,10 @@ export function createHandler(deps) { res.end("Not found"); } } else if (urlPath.startsWith("/assets/")) { - const resolved = resolve(distDir, normalize(urlPath.slice(1))); - if (!resolved.startsWith(resolve(distDir))) { + const root = resolve(distDir); + const resolved = resolve(root, normalize(urlPath.slice(1))); + const rel = relative(root, resolved); + if (rel.startsWith("..") || isAbsolute(rel)) { res.writeHead(403); res.end("Forbidden"); return; } serveStatic(res, resolved); diff --git a/packages/apm-contributor-dashboard/tests/server.test.mjs b/packages/apm-contributor-dashboard/tests/server.test.mjs index 14e4ac1b4..775c144b1 100644 --- a/packages/apm-contributor-dashboard/tests/server.test.mjs +++ b/packages/apm-contributor-dashboard/tests/server.test.mjs @@ -6,7 +6,7 @@ import { describe, it, before, after, beforeEach } from "node:test"; import assert from "node:assert/strict"; import { createServer } from "node:http"; import { createHandler } from "../.apm/extensions/issue-monitor/server-handler.mjs"; -import { join, dirname } from "node:path"; +import { basename, join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dir = dirname(fileURLToPath(import.meta.url)); @@ -617,4 +617,21 @@ describe("Path traversal protection", () => { const res = await fetch(`${baseUrl}/assets/nonexistent.js`); assert.equal(res.status, 404); }); + + it("blocks sibling prefix traversal to dist-evil paths", async () => { + const evilPath = `/assets/../../${basename(DIST_DIR)}-evil/secret.txt`; + const url = new URL(`${baseUrl}${evilPath}`); + const http = await import("node:http"); + const res = await new Promise((resolve) => { + const req = http.request({ + hostname: url.hostname, + port: url.port, + path: evilPath, + method: "GET", + }, resolve); + req.end(); + }); + + assert.equal(res.statusCode, 403); + }); }); From d59e2e178ce69f2d85ce5ecf0b2472428fc8ce15 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Sun, 5 Jul 2026 01:17:44 +0200 Subject: [PATCH 2/3] fix(dashboard): tighten parent traversal containment check --- .../.apm/extensions/issue-monitor/server-handler.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/apm-contributor-dashboard/.apm/extensions/issue-monitor/server-handler.mjs b/packages/apm-contributor-dashboard/.apm/extensions/issue-monitor/server-handler.mjs index cf49a70a4..0986ae687 100644 --- a/packages/apm-contributor-dashboard/.apm/extensions/issue-monitor/server-handler.mjs +++ b/packages/apm-contributor-dashboard/.apm/extensions/issue-monitor/server-handler.mjs @@ -7,7 +7,7 @@ // const server = createServer(handler); import { readFileSync } from "node:fs"; -import { isAbsolute, join, normalize, relative, resolve } from "node:path"; +import { isAbsolute, join, normalize, relative, resolve, sep } from "node:path"; import { randomBytes } from "node:crypto"; import { parsePanelReview, extractFollowUpItems } from "./logic.mjs"; @@ -459,7 +459,7 @@ export function createHandler(deps) { const root = resolve(distDir); const resolved = resolve(root, normalize(urlPath.slice(1))); const rel = relative(root, resolved); - if (rel.startsWith("..") || isAbsolute(rel)) { + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { res.writeHead(403); res.end("Forbidden"); return; } serveStatic(res, resolved); From 94ea5beae2dc71c159cbb16930b16de6386ad1fa Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Sun, 5 Jul 2026 01:18:31 +0200 Subject: [PATCH 3/3] fix(dashboard): reject symlinked assets outside dist --- .../issue-monitor/server-handler.mjs | 38 ++++++++++++++-- .../tests/server.test.mjs | 45 +++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/apm-contributor-dashboard/.apm/extensions/issue-monitor/server-handler.mjs b/packages/apm-contributor-dashboard/.apm/extensions/issue-monitor/server-handler.mjs index 0986ae687..1fd1c0fe7 100644 --- a/packages/apm-contributor-dashboard/.apm/extensions/issue-monitor/server-handler.mjs +++ b/packages/apm-contributor-dashboard/.apm/extensions/issue-monitor/server-handler.mjs @@ -6,8 +6,8 @@ // const handler = createHandler({ ghExec, session, startedSessions, ... }); // const server = createServer(handler); -import { readFileSync } from "node:fs"; -import { isAbsolute, join, normalize, relative, resolve, sep } from "node:path"; +import { readFileSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path"; import { randomBytes } from "node:crypto"; import { parsePanelReview, extractFollowUpItems } from "./logic.mjs"; @@ -31,6 +31,33 @@ const MIME_TYPES = { ".ttf": "font/ttf", }; +function isRelativePathUnsafe(relPath) { + return relPath === ".." || relPath.startsWith(`..${sep}`) || isAbsolute(relPath); +} + +function isPathWithinDist(root, candidatePath) { + try { + const canonicalRoot = realpathSync(root); + try { + const canonicalCandidate = realpathSync(candidatePath); + return !isRelativePathUnsafe(relative(canonicalRoot, canonicalCandidate)); + } catch (error) { + if (error.code !== "ENOENT" && error.code !== "ENOTDIR") { + return null; + } + try { + const parent = dirname(candidatePath); + const canonicalParent = realpathSync(parent); + return !isRelativePathUnsafe(relative(canonicalRoot, canonicalParent)); + } catch { + return null; + } + } + } catch { + return null; + } +} + // Write endpoints that perform state-changing operations const WRITE_ENDPOINTS = new Set([ "/start-session", "/open-session", "/run-panel", "/approve-pipeline", @@ -459,7 +486,12 @@ export function createHandler(deps) { const root = resolve(distDir); const resolved = resolve(root, normalize(urlPath.slice(1))); const rel = relative(root, resolved); - if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + if (isRelativePathUnsafe(rel)) { + res.writeHead(403); res.end("Forbidden"); return; + } + + const isSafePath = isPathWithinDist(root, resolved); + if (isSafePath === false) { res.writeHead(403); res.end("Forbidden"); return; } serveStatic(res, resolved); diff --git a/packages/apm-contributor-dashboard/tests/server.test.mjs b/packages/apm-contributor-dashboard/tests/server.test.mjs index 775c144b1..6cdaf33c4 100644 --- a/packages/apm-contributor-dashboard/tests/server.test.mjs +++ b/packages/apm-contributor-dashboard/tests/server.test.mjs @@ -4,9 +4,11 @@ import { describe, it, before, after, beforeEach } from "node:test"; import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { createHandler } from "../.apm/extensions/issue-monitor/server-handler.mjs"; import { basename, join, dirname } from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; const __dir = dirname(fileURLToPath(import.meta.url)); @@ -595,6 +597,49 @@ describe("Path traversal protection", () => { before(() => setupServer()); after(teardownServer); + it("rejects symlinked assets that resolve outside dist directory", async () => { + if (process.platform === "win32") { + return; + } + + const workDir = await mkdtemp(join(tmpdir(), "apm-issue-monitor-test-")); + const distDir = join(workDir, "dist"); + const evilRoot = join(workDir, "outside"); + const evilFile = join(evilRoot, "secret.txt"); + const evilRequestPath = `/assets/${basename(DIST_DIR)}-evil/secret.txt`; + + await mkdir(distDir, { recursive: true }); + await mkdir(evilRoot, { recursive: true }); + await writeFile(evilFile, "top-secret", "utf8"); + await symlink(evilRoot, join(distDir, `${basename(DIST_DIR)}-evil`), "dir"); + + const state = createMockDeps({ distDir }); + const handler = createHandler(state.deps); + const symlinkServer = createServer(handler); + + try { + await new Promise((resolve) => { + symlinkServer.listen(0, "127.0.0.1", resolve); + }); + const url = new URL(`http://127.0.0.1:${symlinkServer.address().port}`); + const http = await import("node:http"); + const res = await new Promise((resolve) => { + const req = http.request({ + hostname: url.hostname, + port: url.port, + path: evilRequestPath, + method: "GET", + }, resolve); + req.end(); + }); + + assert.equal(res.statusCode, 403); + } finally { + await new Promise((resolve) => symlinkServer.close(resolve)); + await rm(workDir, { recursive: true, force: true }); + } + }); + it("blocks path traversal attempts via encoded dots in /assets/", async () => { // Use raw HTTP to send encoded path traversal that bypasses fetch URL normalization const url = new URL(`${baseUrl}/assets/%2e%2e/package.json`);