security: gate download + scrape through validateNavigationUrl (SSRF)#1029
Open
garagon wants to merge 1 commit intogarrytan:mainfrom
Open
security: gate download + scrape through validateNavigationUrl (SSRF)#1029garagon wants to merge 1 commit intogarrytan:mainfrom
garagon wants to merge 1 commit intogarrytan:mainfrom
Conversation
The `goto` command was correctly wired through validateNavigationUrl, but `download` and `scrape` called page.request.fetch(url, ...) directly. A caller with the default write scope could hit the /command endpoint and ask the daemon to fetch http://169.254.169.254/latest/meta-data/ (AWS IMDSv1) or the GCP/Azure/internal equivalents. The response body comes back as base64 or lands on disk where GET /file serves it. Fix: call validateNavigationUrl(url) immediately before each page.request.fetch() call site in download and in the scrape loop. Same blocklist that already protects `goto`: file://, javascript:, data:, chrome://, cloud metadata (IPv4 all encodings, IPv6 ULA, metadata.*.internal). Tests: extend browse/test/url-validation.test.ts with a source-level guard that walks every `await page.request.fetch(` call site and asserts a validateNavigationUrl call precedes it within the same branch. Regression trips before code review if a future refactor drops the gate.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
gotoalready routes every URL throughvalidateNavigationUrl. Thedownloadandscrapecommands don't. They callpage.request.fetch(url, { timeout: 30000 })directly against a caller-supplied URL, and the response body either comes back to the client as base64 or lands on disk whereGET /fileserves it.That means a caller with the default write scope can do:
and receive the AWS IMDSv1 body. Same trick with
metadata.google.internal,metadata.azure.internal,[fd00::],0xA9FEA9FE, or any internal IPv4/IPv6 the daemon happens to route to. Thescrapecommand multiplies the exposure because it loops over every<a href>on a page and fetches each one with the same unguarded path, so a single attacker-controlled page turns into N unvalidated fetches.The interesting part is that the blocklist already exists and already protects
goto. The two call sites just don't use it.Root cause
browse/src/write-commands.ts— thedownloadbranch (around line 1002) and the innerforloop of thescrapebranch (around line 1099) both go straight fromconst urltopage.request.fetch(url, ...). No SSRF gate, no scheme check, no metadata-host check. This sits behindvalidateAuth, but the write-scope bearer is the same token that/tunnel/startexposes and the one closed by #1026, so treating the auth boundary as a trust boundary for the outbound HTTP client is a weaker posture thangotoalready takes.Fix
Call
validateNavigationUrl(url)immediately before eachpage.request.fetchcall site. The validator already covers:http/https) — blocksfile://,javascript:,data:,chrome://.metadata.google.internal,metadata.azure.internal) including trailing-dot variants.169.254.169.254in every encoding (dotted, hex0xA9FEA9FE, decimal2852039166, octal0251.0376.0251.0376).fc00::/7,fd00::and friends) with bracket-form URL parsing.fetch.So the fix is one
awaiton each side. No new allowlist, no new config, no behavior change for callers that were already passing valid public URLs.Reproduction (before the fix)
After the fix, the same request returns
URL scheme "http://169.254.169.254" is not allowed (cloud metadata).Sibling review
Grepped
browse/src/for everypage.request.fetch(call that takes a caller-supplied URL:write-commands.tsdownload (~1012)args[0]write-commands.tsscrape loop (~1113)<a href>harvested from pagegotocommand (server.ts)args[0]validateNavigationUrl.request.fetch(in browseThe new regression test walks
browse/src/write-commands.tsand asserts everyawait page.request.fetch(sits under a precedingvalidateNavigationUrlcall. If a future refactor adds a third caller-facing fetch path or drops one of these gates, the test turns red before the diff reaches review.Tests
browse/test/url-validation.test.tsalready covers the validator's blocklist (30 cases: schemes, metadata in every encoding, IPv6 ULA edges, hostname false positives). This PR adds adownload + scrape SSRF gatedescribe block that readswrite-commands.tsas a string and asserts the source-level invariant:Framework-independent, DB-free, runs in under a second.
bun test browse/test/url-validation.test.ts: 30 pass, 0 fail.Negative control
Reverting
browse/src/write-commands.tstoorigin/mainand rerunning:All three failures are the new regression tests. Applying the fix: 30/30 pass.
What stayed the same
validateNavigationUrl— same allowlist, same blocklist.gotowas already correct and remains unchanged.downloadblob-URL branch (different strategy, feeds offpage.evaluateand base64-decodes) is not affected since it never hitspage.request.fetch.Files
How to verify