Skip to content

Security: SSRF denylist at the capture URL seams - #3

Closed
Seanathon wants to merge 1 commit into
dev-capture-archive-composefrom
dev-ssrf-denylist
Closed

Security: SSRF denylist at the capture URL seams#3
Seanathon wants to merge 1 commit into
dev-capture-archive-composefrom
dev-ssrf-denylist

Conversation

@Seanathon

Copy link
Copy Markdown
Owner

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 the board-oss-capture-ssrf memory.)

Stacked on dev-capture-archive-compose (PR #2) because the guard also covers that branch's snapshot seam. Retarget to main once #2 merges. Diff here is the SSRF work only.

What it does

Adds capture/net-guard.tsassertCapturableUrl(url), called at the two seams every server user-URL fetch routes through:

  • dispatchCapture — covers url-screenshot (page.goto) + url-readable (captureLibraryfetch/renderPageText), i.e. create / /share / assign / refetch. Buffer (manual-upload) sources have no URL and fall through.
  • the snapshot capture() — archival, before Chrome even launches.

browser.ts / add.ts are intentionally out of scope: they're operator-run CLIs (operator supplies the URL) or downstream of the guarded adapters.

The guard:

  • scheme allowlist (http/https only);
  • range checks via 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);
  • numeric-encoded IPv4 (decimal 2130706433, hex 0x7f000001);
  • localhost-family hostnames (localhost, *.local, *.internal, *.localhost, metadata.google.internal);
  • DNS-resolve-and-recheck — the real evil.com → 10.0.0.5 vector (injectable for tests).

Fail-closed / fail-clean: a blocked URL throws BlockedUrlError → the item's existing capture-error path marks it error, 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 prove dispatchCapture blocks before the adapter runs and that buffer sources pass through.

npm test508 pass / 0 fail. Zero new dependencies (node:net / node:dns).

Residuals (documented, not closed)

  • DNS-rebinding TOCTOU — puppeteer/fetch re-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.
  • Octal-per-octet / mixed numeric IPv4 encodings (0177.0.0.1) that net.isIP rejects.
  • Exotic IPv6 forms net.isIP rejects.

These are denylist gaps, not regressions — the guard is a large net improvement over no check on a self-hosted box.

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>
@Seanathon
Seanathon deleted the branch dev-capture-archive-compose June 24, 2026 06:19
@Seanathon Seanathon closed this Jun 24, 2026
@Seanathon
Seanathon deleted the dev-ssrf-denylist branch June 24, 2026 06:47
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