Security: SSRF denylist at the capture URL seams - #3
Closed
Seanathon wants to merge 1 commit into
Closed
Conversation
Server-side capture could be steered at internal addresses (cloud metadata 169.254.169.254, localhost admin panels, RFC1918) — pre-existing and app-wide, reachable from every create path (POST /items, /share, assign, refetch) plus archival snapshots. Add assertCapturableUrl() and call it at the two seams every server user-URL fetch routes through: dispatchCapture (url-screenshot + url-readable) and the snapshot capture(). Buffer (manual-upload) sources fall through; operator CLIs (add.ts) are out of scope. Guard = scheme allowlist (http/https) + private/loopback/link-local/ULA/CGNAT ranges via net.BlockList (IPv4, IPv6 ::1, IPv4-mapped) + numeric-encoded IPv4 (decimal/hex) + localhost-family hostnames + a DNS-resolve-and-recheck (the real evil.com→10.0.0.5 vector). Fail-closed/clean: a blocked URL throws → the item's capture-error path marks it `error`, no crash. Zero new deps (node:net/dns). Residuals (documented): DNS-rebinding TOCTOU (puppeteer/fetch re-resolve at connect time); octal-per-octet / mixed numeric encodings; IPv6 forms net.isIP rejects. Table-driven bypass test (userinfo, [::ffff:127.0.0.1], 2130706433, 0x7f000001, scheme, DNS→private). Suite 508 pass / 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Seanathon
added a commit
that referenced
this pull request
Jun 24, 2026
…erialized-fields §A housekeeping done; SSRF (PR #3), snapshot-delete + backfill-lock (PR #2), and two source strict-type gaps (fc67fe2) resolved. Still open: materialized-board field editability (design), zero-tsc-and-gate-CI, server.ts dead code (after PR #2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
SSRF denylist at the capture URL seams
Server-side capture could be steered at internal addresses — cloud metadata (
169.254.169.254), localhost admin panels, RFC1918 — by any user-supplied URL. This is pre-existing and app-wide, reachable from every create path (POST /api/v1/items,POST /share, assign-driven capture, refetch) plus archival snapshots. (Tracked in the post-batch backlog ticket and theboard-oss-capture-ssrfmemory.)What it does
Adds
capture/net-guard.ts→assertCapturableUrl(url), called at the two seams every server user-URL fetch routes through:dispatchCapture— coversurl-screenshot(page.goto) +url-readable(captureLibrary→fetch/renderPageText), i.e. create //share/ assign / refetch. Buffer (manual-upload) sources have no URL and fall through.capture()— archival, before Chrome even launches.browser.ts/add.tsare intentionally out of scope: they're operator-run CLIs (operator supplies the URL) or downstream of the guarded adapters.The guard:
http/httpsonly);net.BlockList(zero dep):0/8,10/8,127/8,169.254/16,172.16/12,192.168/16,100.64/10, IPv6::1/::/fe80::/10/fc00::/7, and IPv4-mapped IPv6 (::ffff:127.0.0.1);2130706433, hex0x7f000001);localhost,*.local,*.internal,*.localhost,metadata.google.internal);evil.com → 10.0.0.5vector (injectable for tests).Fail-closed / fail-clean: a blocked URL throws
BlockedUrlError→ the item's existing capture-error path marks iterror, no worker/server crash.Tests
Table-driven on bypass forms (the discriminator, not just "127.0.0.1 blocked"): userinfo (
example.com@127.0.0.1),[::1],[::ffff:127.0.0.1],0.0.0.0, numeric encodings, non-http schemes, invalid URLs, and a DNS-resolves-to-private case — plus allowed cases (public literal, public hostname, and a resolution-failure that's allowed so the real fetch surfaces the error). Two seam tests provedispatchCaptureblocks before the adapter runs and that buffer sources pass through.npm test→ 508 pass / 0 fail. Zero new dependencies (node:net/node:dns).Residuals (documented, not closed)
fetchre-resolve at connect time, so a host that resolves public for the guard but private for the real fetch isn't caught. Closing it needs connection-time IP pinning those clients don't expose.0177.0.0.1) thatnet.isIPrejects.net.isIPrejects.These are denylist gaps, not regressions — the guard is a large net improvement over no check on a self-hosted box.