Summary
The GET /api/scans/:slug/reports/:id route constructs the reportsRoot directory by calling repoOutputDir(slug). If slug contains directory traversal sequences (e.g. ../../another-dir), repoOutputDir resolves the path relative to the traversal target, not the expected output directory. The subsequent isInside(reportsRoot, reportPath) check then evaluates true for any file inside the attacker-controlled directory — bypassing the containment boundary entirely.
Panel verdict: P0, VERIFIED, EXISTING_DEFECT. Elevated from initial P2 assessment after Judge ruled Hawk's analysis correct.
Affected Files
| File |
Lines |
Issue |
src/server/routes.ts |
L402–L418 |
:slug not validated before use in repoOutputDir(slug) |
src/server/routes.ts |
L300–L333 |
GET /api/scans/:slug — same slug-based path construction |
src/server/routes.ts |
L383–L400 |
GET /api/scans/:slug/reports — same slug-based path |
Root Cause — Code Evidence
Route handler (src/server/routes.ts, lines 402–418):
router.get('/scans/:slug/reports/:id', (req, res) => {
const slug = req.params.slug; // ← NOT validated for traversal sequences
const id = req.params.id;
if (!/^[a-zA-Z0-9._-]+\.md$/.test(id)) { // ← :id is validated
return res.status(400).json({ error: 'Invalid report id' });
}
const outputDir = repoOutputDir(slug); // ← slug may contain ../../
const reportsRoot = path.join(outputDir, 'reports');
const reportPath = path.join(reportsRoot, id);
// Defense-in-depth: ensure path resolves inside the reports dir.
if (!isInside(reportsRoot, reportPath)) { // ← reportsRoot is already wrong
return res.status(400).json({ error: 'Invalid report path' });
}
// ...
const markdown = readFileSync(reportPath, 'utf8'); // ← reads from traversed path
res.json({ id, markdown });
});
isInside utility (src/server/routes.ts, lines 123–126):
function isInside(parent: string, child: string): boolean {
const rel = path.relative(parent, child);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
}
// ↑ Correct logic, but only works when 'parent' itself is trusted.
// If slug = '../../etc', parent = '/home/user/.probus/output/../../etc/reports'
// = '/etc/reports'
// And child = '/etc/reports/somefile.md' → rel = 'somefile.md' → isInside = true
Attack Scenario
Attacker crafts a GET request:
GET /api/scans/..%2F..%2Fetc/reports/passwd.md HTTP/1.1
Host: 127.0.0.1:9090
After URL decoding and path.join, the server reads /etc/reports/passwd.md (or any file in any directory that happens to contain a .md file).
On macOS and Linux, attackers can target:
~/.ssh/ — private SSH keys
~/.aws/ — cloud credentials
- Any source file in the project tree
Steps to Reproduce
- Create a directory
/tmp/reports/ with a markdown file: echo "secret" > /tmp/reports/test.md
- Start Probus:
npm run dev
- Make a request:
curl 'http://127.0.0.1:9090/api/scans/..%2F..%2F..%2Ftmp/reports/test.md'
- Observe the content of
test.md returned in the JSON response.
Remediation
Step 1 — Validate :slug with a strict allowlist regex
Add slug validation immediately in every route that uses it:
// src/server/routes.ts — add near the validId() helper (line 433)
const validSlug = (s: string) => /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(s);
// Apply in every :slug route:
router.get('/scans/:slug/reports/:id', (req, res) => {
const { slug, id } = req.params;
if (!validSlug(slug)) return res.status(400).json({ error: 'Invalid slug' });
if (!/^[a-zA-Z0-9._-]+\.md$/.test(id)) return res.status(400).json({ error: 'Invalid report id' });
// ... rest unchanged
});
Step 2 — Defense-in-depth: validate outputDir is inside expected base
import { OUTPUT_BASE_DIR } from '../paths.js'; // the root outputs directory
const outputDir = repoOutputDir(slug);
if (!isInside(OUTPUT_BASE_DIR, outputDir)) {
return res.status(400).json({ error: 'Invalid slug: path traversal detected' });
}
References
Summary
The
GET /api/scans/:slug/reports/:idroute constructs thereportsRootdirectory by callingrepoOutputDir(slug). Ifslugcontains directory traversal sequences (e.g.../../another-dir),repoOutputDirresolves the path relative to the traversal target, not the expected output directory. The subsequentisInside(reportsRoot, reportPath)check then evaluatestruefor any file inside the attacker-controlled directory — bypassing the containment boundary entirely.Panel verdict: P0, VERIFIED, EXISTING_DEFECT. Elevated from initial P2 assessment after Judge ruled Hawk's analysis correct.
Affected Files
src/server/routes.ts:slugnot validated before use inrepoOutputDir(slug)src/server/routes.tsGET /api/scans/:slug— same slug-based path constructionsrc/server/routes.tsGET /api/scans/:slug/reports— same slug-based pathRoot Cause — Code Evidence
Route handler (
src/server/routes.ts, lines 402–418):isInsideutility (src/server/routes.ts, lines 123–126):Attack Scenario
Attacker crafts a GET request:
After URL decoding and
path.join, the server reads/etc/reports/passwd.md(or any file in any directory that happens to contain a.mdfile).On macOS and Linux, attackers can target:
~/.ssh/— private SSH keys~/.aws/— cloud credentialsSteps to Reproduce
/tmp/reports/with a markdown file:echo "secret" > /tmp/reports/test.mdnpm run devcurl 'http://127.0.0.1:9090/api/scans/..%2F..%2F..%2Ftmp/reports/test.md'test.mdreturned in the JSON response.Remediation
Step 1 — Validate :slug with a strict allowlist regex
Add slug validation immediately in every route that uses it:
Step 2 — Defense-in-depth: validate outputDir is inside expected base
References