Browser-local incident planner
Find the first broken confirmation boundary
Classify four observable states. All 256 combinations are evaluated locally.
- Identity
- Limiter
- Intent
- Delivery
- Boundary
- Next action
- Exit condition
- Safety
No email address, IP address, forwarded header, domain, token, message, configuration, credential, request body, or log is requested or transmitted by the planner.
Recognize a shared-bucket failure
A live incident in eventPhotos showed the pattern clearly. The host accepted public IPv6, while the application path behind an IPv4-only Docker bridge saw many IPv6 visitors as the same bridge gateway. Symfony's per-client signup limiter then treated unrelated visitors as one caller. After five accepted requests in the original one-hour bucket, later requests returned 429 before a subscription row or confirmation send existed.
The failure looked intermittent because IPv4 visitors still presented distinct addresses. It also stayed out of the production log: the Monolog fingers_crossed handler activated at error, while the application threw a normal too-many-requests response without a dedicated rejection metric.
Repair identity without trusting the internet
- Run controlled IPv4 and IPv6 requests through the real public edge. Compare the edge peer, emitted
ForwardedorX-Forwarded-Forchain, web-server remote address, and framework client identity privately. - Choose a topology deliberately. A native dual-stack bridge keeps network isolation; a host-network edge avoids bridge NAT and userland proxying on Linux; a separate load balancer can preserve identity through an authenticated internal hop.
- At the public edge, remove any forwarding header supplied by the client and write the canonical chain. At each later hop, trust only the exact preceding proxy address or controlled CIDR.
- Repeat the dual-stack test plus a direct spoof attempt. The approved proxy path must preserve identity, while a direct request must not choose its own limiter key.
Do not configure Symfony or nginx to trust every internet source merely to recover X-Forwarded-For. A forged header would let an attacker rotate arbitrary rate-limit identities.
Use different controls for different abuse
- Edge volume
- A coarse network-level control absorbs floods before the application boots. It is not the place to decide whether one recipient may receive another confirmation.
- Client burst
- A broad application limit can slow automated form submission, but its budget must tolerate venue Wi-Fi, office NAT, and carrier CGNAT.
- Recipient resend
- A normalized per-email limit is the direct mail-bomb control. Keep responses non-enumerating and never log the address.
- Durable work
- After validation and accepted abuse gates, commit one Pending state and one idempotent notification job before claiming success.
Symfony's Rate Limiter exposes remaining tokens, limit, and retry time. A rejected request can produce the standard Retry-After header. Use it: keep the form available and state that no confirmation was scheduled. “Check your inbox” is false when the limiter stopped execution before any send attempt.
Make notification state queryable
A subscription row alone cannot distinguish “never attempted” from “provider accepted” or “send failed.” Store a monotonic state for the current confirmation generation: queued, attempted, provider_accepted, retryable_failed, or terminal_failed. Correlate with an opaque provider identifier, not a recipient or token in logs.
Provider acceptance is not inbox placement. Keep provider events, recipient-server acceptance, mailbox visibility, and confirmation-link completion as separate evidence.
For observability, count limiter name, route, outcome, address family, retry window, and privacy-preserving bucket cardinality. Route those counters independently from an error-triggered log buffer. This reveals a sudden one-bucket collapse without retaining raw IPs or forwarding chains.
Recover only the current Pending generation
- Claim the current idempotent job with a lease and recheck that the subscription is still
Pending. - Apply bounded exponential backoff to retryable transport failures. Mark permanent provider rejection separately.
- Never resend to
ConfirmedorUnsubscribed. A repeat confirmation tap may be idempotent, but an opt-out remains terminal. - Refresh a token only when starting a deliberate new pending generation. An older link must not confirm the newer state.
- Finish with one controlled provider event, receiver-visible message, and single confirmation transition.
The free scan checks public MX, SPF, DMARC, PTR, and policy evidence. It cannot inspect Docker, proxy trust, private limiter state, an outbox, provider events, or a mailbox.
Primary references
- eventPhotos issue 123: live confirmation-email rate-limit incident and follow-up
- Reviewed eventPhotos network and limiter fix
- Reviewed subscription, limiter, and synchronous send path
- Reviewed production Monolog activation threshold
- Symfony: trusted proxies and forwarded headers
- Symfony: rate-limit keys, status, and Retry-After
- Docker: host network driver and isolation boundary
- Docker: bridge networks, NAT, and optional IPv6
- Docker: enabling IPv6 networking
- RFC 6585 section 4: 429 Too Many Requests