Skip to content

fix(security): validateOutputPath bypassed via existing symlinks in safe directories#921

Open
Hybirdss wants to merge 1 commit intogarrytan:mainfrom
Hybirdss:fix/validate-output-path-symlink-bypass
Open

fix(security): validateOutputPath bypassed via existing symlinks in safe directories#921
Hybirdss wants to merge 1 commit intogarrytan:mainfrom
Hybirdss:fix/validate-output-path-symlink-bypass

Conversation

@Hybirdss
Copy link
Copy Markdown

@Hybirdss Hybirdss commented Apr 8, 2026

Summary

validateOutputPath() only resolves the parent directory — if the target file already exists as a symlink pointing outside safe directories, the write follows the symlink.

Every output command is affected: screenshot, pdf, download, scrape, archive.

Reproduction

ln -s /etc/crontab /tmp/browse-screenshot-1234.png
$B screenshot /tmp/browse-screenshot-1234.png
# validateOutputPath passes: parent is /tmp (safe)
# fs.writeFileSync follows symlink → overwrites /etc/crontab

Root cause

File: browse/src/path-security.ts:33-56

validateOutputPath resolves the parent directory via realpathSync(dir) but never checks the file itself. This is correct for new files (they don't exist yet), but misses the case where the target already exists as a symlink.

Compare with validateReadPath (line 59-80) which does realpathSync(resolved) on the file itself — that function is not vulnerable.

Fix

Before the parent-directory check, lstatSync the resolved path. If it exists and is a symlink, realpathSync the target and verify it's within SAFE_DIRECTORIES. ENOENT (file doesn't exist yet) falls through to the existing parent-dir check.

try {
  const stat = fs.lstatSync(resolved);
  if (stat.isSymbolicLink()) {
    const realTarget = fs.realpathSync(resolved);
    if (!SAFE_DIRECTORIES.some(dir => isPathWithin(realTarget, dir))) {
      throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
    }
    return;
  }
} catch (e: any) {
  if (e.code !== 'ENOENT') throw e;
}

1 file, 20 lines added. All 6 output commands protected through the single shared function.

Related

Test plan

  • Pre-existing test "blocks symlink inside /tmp pointing outside safe dirs" now passes (was failing since the test was written)
  • bun test browse/test/path-validation.test.ts — 29 pass, 0 fail (was 29 pass, 1 fail)
  • bun test — full suite passes, 0 failures
  • PoC verified: ln -s /etc/crontab /tmp/test.png + validateOutputPath('/tmp/test.png') → throws
  • Normal /tmp writes still pass (no regression)

Generated with Claude Code

…links

A symlink at /tmp/evil.png → /etc/crontab passes validateOutputPath because
the function only resolves the parent directory (/tmp is safe), never checking
if the target file itself is a symlink pointing outside safe directories.

Every output command is affected: screenshot, pdf, download, scrape, archive.

Fix: before the parent-dir check, lstatSync the resolved path. If it exists
and is a symlink, realpathSync the target and verify it's within SAFE_DIRECTORIES.
ENOENT (file doesn't exist yet) falls through to the existing parent-dir check.

Fixes the pre-existing test failure in path-validation.test.ts
("blocks symlink inside /tmp pointing outside safe dirs").
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant