Skip to content

Security review fixes: SSRF resolution and pinning, error oracle, fail2ban injection, identity casing, token expiry, rate limits, headers - #5

Merged
y3v63n merged 10 commits into
fix/test-roundfrom
fix/security-review
Sep 7, 2026
Merged

Security review fixes: SSRF resolution and pinning, error oracle, fail2ban injection, identity casing, token expiry, rate limits, headers#5
y3v63n merged 10 commits into
fix/test-roundfrom
fix/security-review

Conversation

@y3v63n

@y3v63n y3v63n commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Closes the three High and four Medium findings of the 2026-09-07 security review. Stacks on PR #4; GitHub retargets to main when #4 merges. Each task reviewed independently, plus a whole-branch review and one fix wave.

Finding Fix
H-1 Webhook SSRF: host blocklist never resolved DNS src/core/safe-url.ts resolves the name, refuses any address in loopback, link-local, RFC 1918, CGNAT/tailnet 100.64/10, multicast, reserved, IPv6 unique-local/link-local/site-local, IPv4-mapped, IPv4-compatible, NAT64 and 6to4 forms, refuses localhost/.local/.internal/.home.arpa names (trailing dot included), then pins the connection through an undici Agent whose connect-time lookup returns only the vetted addresses. Used by registration and by every delivery. undici pinned as a dependency. A test dispatches through the pin to a local server.
H-2 Upstream status and errors returned to anonymous callers One generic message; detail to the server log keyed by subscription id.
H-3 fail2ban log injection via the Postgres username failregex anchored on %m [%p] %h; files/failregex-check.py proves benign, forged, IPv6 and socket lines.
M-1 Case-sensitive publisher identity Lowercased at every boundary and comparison (Auth0 claim, Tailscale header, dev fallback, seed script, four-eyes confirm/schedule/withdraw/reject). Migration 015 collapses duplicates (ctid tie-break) and adds a check constraint.
M-2 Confirmation tokens replayable forever Migration 016 adds verify_token_issued_at; 72-hour window; token cleared on use.
M-3 No rate limiting Migration 017 rate_limits, atomic fixed-window upsert. Email 3/h per address and 10/h per IP, webhook 5/h per IP. Email format and length checks; URL length cap. Both email limits consumed before deciding, so the refusal reveals nothing.
M-4 No security headers CSP (frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'), X-Frame-Options, nosniff, Referrer-Policy, HSTS, Permissions-Policy from next.config.mjs. script-src/style-src left for a nonce follow-up.
Found during review Migration 018 lowercases email subscriber endpoints (verified row wins, then earliest) and adds a check constraint, so lowercased input cannot orphan existing mixed-case subscriptions.

Tests: 738 passing (645 + 93). Typecheck and web:build green. The db-tls integration file needs the port-5500 container and was not run here.

Deploy steps

  1. Merge PR Test-round fixes: configurable broadcast links, STE webhook page, Telegram error text, doc corrections #4, then this PR.
  2. Before migrating, look at what the two collapse migrations will remove:
    select lower(email), count(*) from publishers group by 1 having count(*) > 1;
    select lower(endpoint), count(*) from subscriptions where channel = 'email' group by 1 having count(*) > 1;
  3. npm run migrate as the announce owner (015–018; 015 alters a table and 017 grants).
  4. Re-run the Ansible playbook (or the fail2ban role) so the new filter lands; then fail2ban-client status announce-postgres on the VM.
  5. No new environment variables. npm ci on the build picks up undici.

🤖 Generated with Claude Code

y3v63n and others added 10 commits September 7, 2026 15:19
… connection

The host blocklist matched hostname text and never resolved DNS, so any
attacker-owned name pointing at loopback, the tailnet range or a metadata
address passed. The URL is now resolved first, every address is checked
against the forbidden ranges (IPv4 and IPv6, mapped and NAT64 forms), and
the request is dispatched through an agent whose connect-time lookup
returns only the vetted address, closing the rebinding window.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…sed ranges

The connect-time lookup used the three-argument callback form. Node enables
autoSelectFamily by default, so the socket layer calls a custom lookup with
{ all: true } and rejects that form with 'Invalid IP address: undefined',
which failed every pinned request. It now answers with the full array of
vetted addresses, which also restores failover for multi-homed endpoints.
A test that dispatches for real covers this; the callback type is a union,
so the wrong shape type-checks and only a live request catches it.

A trailing root dot no longer bypasses the forbidden-name list. The refused
set gains IPv4-compatible, site-local, 6to4 and local-use NAT64 addresses.
The note about a production guard on the private-host flag described
something that does not exist and now describes the actual situation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The upstream HTTP status and the raw exception text (connection refused,
DNS error, timeout) were returned straight to the anonymous browser
caller. That turns a blind server-side verification request into a port
scan oracle of whatever the URL pointed at: a caller could learn whether
a given internal host or port is open just by reading the error text.

registerWebhook now returns one constant, ENDPOINT_NOT_VERIFIED, for
every failure mode of the verification POST. The real detail (status or
truncated exception message) still goes to console.warn, keyed by the
subscription id, for operator debugging.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…annot forge the banned address

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…owercase

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…gration, and normalise identity case everywhere it is compared

The publishers-lowercase migration's delete had no tie-break: added_at
defaults to the transaction start time, so multiple casings inserted in one
transaction get an identical timestamp, the delete removes nothing, and the
following lowercase update then collides on the primary key. Order on
(added_at, ctid) instead, which keeps "earliest wins" while guaranteeing a
strict order between any two rows. Proven against a temporary table shaped
like publishers, since the migration itself already applied cleanly against a
table with no duplicate casings.

withdrawPublish and rejectPublish still compared the requester to the acting
publisher case-sensitively, missing the same normalisation four-eyes already
got. resolveIdentity now lowercases and trims all three identity sources
(Auth0 header, Tailscale header, dev fallback) at the one place identity is
resolved, instead of relying on each downstream comparison to do it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ed fixed windows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds CSP (framing/plugins/base-uri/form-action only, no script-src or
style-src yet), X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
Strict-Transport-Security, and Permissions-Policy via next.config.mjs
headers(). Verified form-action 'self' cannot break the Auth0 login
flow: the login route answers with a redirect and the callback is a GET,
neither posts a form.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…l2ban check

Four fixes from the final review round.

1. Lowercasing the subscriber address in the public action orphaned any
   subscription row written earlier in mixed case: the lookups in
   startEmailSubscription compare endpoint exactly, so a returning subscriber
   missed their own row, got a second one, and received every announcement
   twice. Migration 018 collapses the duplicate casings of each email address
   (keeping the verified row where exactly one is verified, otherwise the
   earliest by (created_at, ctid)), lowercases what remains, and adds a check
   constraint refusing future mixed-case email rows. Webhook endpoints are left
   alone, since a URL path is case-sensitive. startEmailSubscription now
   normalises the address itself rather than trusting its callers, so the
   subscriber-adding script cannot reintroduce mixed case; that script
   lowercases its argument too.

2. The offline fail2ban check used a <HOST> expansion that matched only IPv4
   and hostnames, so a bare IPv6 client address went unmatched. It now mirrors
   fail2ban 1.0's alternation (the version the distro package installs) and
   covers two more lines: an IPv6 failure, and an IPv6 failure whose username
   carries a forged address. The jail's own failregex is unchanged, since
   fail2ban does that expansion itself.

3. Comments in the client-address helper record that the x-forwarded-for
   fallback is only safe behind a proxy that overwrites the header, and that
   'unknown' is one shared, fail-closed bucket.

4. The tsconfig note listing the deep-import sites for the headers module was
   missing the public actions file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@y3v63n
y3v63n merged commit 90343ad into fix/test-round Sep 7, 2026
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