Skip to content

[P0][SECURITY] Path traversal via :slug parameter shifts containment root, bypassing isInside() check #5

Description

@pratik-saptarshi

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

  1. Create a directory /tmp/reports/ with a markdown file: echo "secret" > /tmp/reports/test.md
  2. Start Probus: npm run dev
  3. Make a request: curl 'http://127.0.0.1:9090/api/scans/..%2F..%2F..%2Ftmp/reports/test.md'
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions