Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
// const handler = createHandler({ ghExec, session, startedSessions, ... });
// const server = createServer(handler);

import { readFileSync } from "node:fs";
import { join, resolve, normalize } 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";

Expand All @@ -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",
Expand Down Expand Up @@ -456,8 +483,15 @@ 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 (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);
Comment on lines +486 to 497
Expand Down
64 changes: 63 additions & 1 deletion packages/apm-contributor-dashboard/tests/server.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { join, dirname } from "node:path";
import { basename, join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";

const __dir = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -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`);
Expand All @@ -617,4 +662,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);
});
});