Skip to content

fix(security): bucket IPv4 trust to /24 and decouple verify-IP OTP send#1420

Merged
joelorzet merged 1 commit into
stagingfrom
fix/verify-ip-ipv4-24-trust
May 31, 2026
Merged

fix(security): bucket IPv4 trust to /24 and decouple verify-IP OTP send#1420
joelorzet merged 1 commit into
stagingfrom
fix/verify-ip-ipv4-24-trust

Conversation

@joelorzet
Copy link
Copy Markdown

@joelorzet joelorzet commented May 30, 2026

Problem

A 2FA user signing in from a new network sees the /verify-ip modal ("Confirm this new sign-in") but never receives the email code, and the prompt spins on "Sending...".

Root cause

normalizeIpForTrust bucketed IPv6 to /64 but left IPv4 byte-exact (/32). When a user's public source IP rotates within one provider block (CGNAT, dual-WAN, and mobile pools are common, especially in LATAM), the host octet differs within the same /24 between two connections seconds apart: the page navigation that mints the pending_ip_verify cookie, and the OTP-send POST to /api/user/verify-ip.

The verify-IP route ran the IP re-check at the top of the handler, gating the OTP send itself. A rotated host failed the byte-exact match and returned ip_mismatch (a 401) before sending. The modal's prefetch never receives factors_required, so it stays in the pre-send "Sending..." state and no email is dispatched. The early-return paths logged nothing, so the failure was invisible.

Changes

  • IPv4 buckets to /24 in normalizeIpForTrust, mirroring the existing IPv6 /64 rule. formatIpForDisplay renders the normalized key as CIDR.
  • OTP send is no longer IP-gated. The IP-binding check moves to the both-codes / session-minting branch only. Requesting the code never depends on a rotating address; only minting the session still binds to the pinned network.
  • SendGrid call gets an AbortSignal timeout so a stalled send fails closed (returns false) instead of hanging the awaiting request indefinitely.
  • [ip-verify] tracing records the raw pre-normalization IP next to the normalized key at the proxy gate, trust assessment, and every verify-IP early return, so a rotating egress is greppable end to end.

Operational notes

  • Existing IPv4 rows in user_trusted_ips are stored as full hosts and will not match the new /24 key, so affected users re-verify once; after that the /24 is trusted and rotation within the block stops prompting.
  • A follow-up backfill (zeroing the last IPv4 octet, with dedup for the unique (user_id, ip) constraint) could skip that one re-verification. Not included here; would land as a deliberate migration.

Security tradeoff

/24 trust covers up to 256 host addresses in the block, which can include other CGNAT subscribers behind the same pool. For a gate that sits on top of full MFA this is a reasonable trade and matches the IPv6 /64 granularity already in use. ASN+country pinning is a tighter alternative if desired later.

The new-network IP gate pinned trust to a byte-exact IPv4 address while
IPv6 was already bucketed to /64. When a user's egress IP rotates within
a provider block (CGNAT, dual-WAN, mobile pools), the host differs in the
same /24 between the page load that mints the pending_ip_verify cookie
and the OTP-send POST, so the re-check returned ip_mismatch before ever
sending the email. The modal renders but the code never arrives and the
prompt spins on "Sending...".

- Bucket IPv4 to its /24 in normalizeIpForTrust, mirroring the IPv6 /64
  rule; render the normalized key as CIDR in formatIpForDisplay.
- Move the IP-binding check off the OTP-send path onto the
  session-minting branch only, so requesting the code is never gated on a
  rotating address.
- Add an AbortSignal timeout to the SendGrid call so a stalled send fails
  closed instead of hanging the awaiting request.
- Add [ip-verify] tracing with the raw pre-normalization IP at the proxy
  gate, trust assessment, and verify-IP early returns.
@joelorzet joelorzet requested review from a team, OleksandrUA, eskp and suisuss and removed request for a team May 30, 2026 22:47
@joelorzet joelorzet merged commit a397f26 into staging May 31, 2026
32 checks passed
@joelorzet joelorzet deleted the fix/verify-ip-ipv4-24-trust branch May 31, 2026 00:26
@github-actions
Copy link
Copy Markdown

🧹 PR Environment Cleaned Up

The PR environment has been successfully deleted.

Deleted Resources:

  • Namespace: pr-1420
  • All Helm releases (Keeperhub, Scheduler, Event services)
  • PostgreSQL Database (including data)
  • LocalStack, Redis
  • All associated secrets and configs

All resources have been cleaned up and will no longer incur costs.

@github-actions
Copy link
Copy Markdown

ℹ️ No PR Environment to Clean Up

No PR environment was found for this PR. This is expected if:

  • The PR never had the deploy-pr-environment label
  • The environment was already cleaned up
  • The deployment never completed successfully

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