T05 · Unauthorized Access and Privilege Escalation
- Location
src/scanner.js:20- Finding
Scan-Root Escape Through Symbolic Links
- Content
View full analysis
Vulnerability Details
File Location:
src/scanner.js:20-37
Vulnerability Type: Improper symbolic-link handling and scan-boundary bypass
Risk Level: MediumVulnerable Code
js export function findFiles(dir, extensions, maxDepth = 4, depth = 0) { if (depth > maxDepth || !existsSync(dir)) return []; const results = []; try { for (const entry of readdirSync(dir)) { if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist' || entry === 'build') continue; const full = join(dir, entry); try { const stat = statSync(full); if (stat.isDirectory()) { results.push(...findFiles(full, extensions, maxDepth, depth + 1)); } else if (extensions.includes(extname(entry))) { results.push(full); } } catch { /* skip inaccessible */ } } } catch { /* skip unreadable dir */ } return results; }Technical Analysis
statSync()follows symbolic links. The scanner does not uselstatSync()to detect links and does not compare each target's canonical path against the canonical scan root.Consequently, a symbolic link inside a scanned project can reference a file or directory outside that project. If a file link has an accepted extension, or a directory link contains files with accepted extensions,
findFiles()returns those external paths. Subsequent diagnostic functions callreadText()orreadJson()on them.The maximum recursion depth limits traversal volume but does not enforce the intended filesystem boundary. The process remains constrained by the operating-system privileges of the user running the CLI.
Attack Path
- An attacker prepares an untrusted project containing a symbolic link to a readable external file or directory.
- The victim runs
frontend-doctoragainst that project. readdirSync()discovers the link andstatSync()follows it.- The scanner treats the target as an ordinary file or directory.
- A diagnostic ...[truncated 643 chars]
- Remediation
View remediation
Remediation Suggestions
- Use
lstatSync()or directory entries with explicit symbolic-link checks and reject links by default. - Canonicalize the initial scan root with
realpathSync(). - Canonicalize every candidate before reading or traversing it.
- Verify that every candidate remains beneath the canonical root using path-segment-aware comparison rather than a raw string prefix.
- Maintain a set of visited canonical directories to prevent cycles.
- Apply the boundary check immediately before file reads as defense in depth and to reduce time-of-check/time-of-use exposure.
- Add tests covering file links, directory links, links to parent directories, link cycles, and similarly prefixed sibling paths.
Example hardening approach:
js import { existsSync, lstatSync, readdirSync, realpathSync, } from 'fs'; import { relative, isAbsolute } from 'path'; function isWithinRoot(root, candidate) { const rel = relative(root, candidate); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); } export function findFiles( dir, extensions, maxDepth = 4, depth = 0, root = realpathSync(dir), visited = new Set() ) { if (depth > maxDepth || !existsSync(dir)) return []; const realDir = realpathSync(dir); if (!isWithinRoot(root, realDir) || visited.has(realDir)) return []; visited.add(realDir); const results = []; for (const entry of readdirSync(realDir)) { if ( entry.startsWith('.') || entry === 'node_modules' || entry === 'dist' || entry === 'build' ) continue; const full = join(realDir, entry); const stat = lstatSync(full); if (stat.isSymbolicLink()) continue; const realFull = realpathSync(full); if (!isWithinRoot(root, realFull)) continue; if (stat.isDirectory()) { results.push( ...findFiles(realFull, extensions, maxDepth, depth + 1, root, visited) ); } else if (extensions.includes(extname(entry))) { results.push(realFull); } } ...[truncated 25 chars]- Use
