Skip to content

fix: stop wrongly refusing first-time signups, and make the cause loud - #442

Merged
Ryanmello07 merged 18 commits into
urnetwork:mainfrom
Ryanmello07:fix/signup-wrongful-rate-limit-refusals
Aug 23, 2026
Merged

fix: stop wrongly refusing first-time signups, and make the cause loud#442
Ryanmello07 merged 18 commits into
urnetwork:mainfrom
Ryanmello07:fix/signup-wrongful-rate-limit-refusals

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

Signup is currently refusing first-time users on production. Reproduced on both deployments from an address that had created one account in ten days:

POST https://api.bringyour.com/auth/network-create        -> 429
POST https://beta.app.ur.network/api/auth/network-create  -> 429
"You have reached the maximum number of account creations for today."

Root cause

ResolveClientAddress returns the immediate peer's address when that peer is not in trustedProxyPrefixes(). BRINGYOUR_TRUSTED_PROXY_CIDRS appears exactly once in the repo — its own const — and is never set, so the default is loopback only while the ingress proxy arrives from a bridge address. Every request in the deployment therefore resolves to one address and shares one budget of 5. The window is sliding, not calendar-day, so it never resets: once five creates land the deployment stays wedged.

The Caddyfile sets X-Forwarded-Source-Port specifically so "the same address feeds rate limiting" — and the trust gate returns before that header is ever read.

What this changes

1. The misconfiguration is now loud. An unenumerated peer arriving with forwarding headers is reported, naming the peer, the header, the current trusted set and the env var — and naming the other possibility (a client sending a header it is not entitled to) so an operator is not misled into whitelisting a spoofer. The peer is not auto-trusted; resolution is unchanged. Rate limited per bucketed peer, then per interval.

2. Standard proxies no longer 500. A first version of this warning told operators to enumerate the proxy — but the trusted branch required X-UR-Forwarded-For as ip:port, or X-Forwarded-For plus X-Forwarded-Source-Port, single-hop. Stock nginx, ALB, CloudFront and Cloudflare send a bare X-Forwarded-For, and $proxy_add_x_forwarded_for appends, so two hops add a rejected comma. That path became HTTP 500 for every endpoint — following the log line would have taken the API down. A bare X-Forwarded-For from an enumerated proxy is now handled, pinned at the HTTP boundary by TestEnumeratedProxySendingOnlyBareXForwardedForIsNotA500.

3. 503 -> 429 for the auth-attempt limiter. 7 call sites across login, verify, code sends and password reset. A 5xx tells every SDK the server is broken and to retry, and every attempt including rejected ones is recorded — so retries were self-reinforcing.

4. Validation mistakes no longer spend budget. The limiter moved below terms, name validation and availability. A taken network name or an unticked terms box was consuming an address-wide slot. Nothing that mutates state sits above the limiter.

5. Honest refusals, with Retry-After. The message told users who had created nothing that they had hit their limit. Wording is parameterized on userAuth, because a blanket "address-scoped" claim is false at 5 of the 7 sites — only the nil-userAuth key is genuinely shared. Account creation computes the real remaining window.

Deliberately not changed

The /29 and /56 masking, every limit value, and the default trusted set. Those are anti-abuse policy, not defects — widening them is your call, not something to slip into a fix. ip.go is untouched.

Verification

Three adversarial review rounds; the final one ran the suite rather than reading it, and re-ran each mutation first-hand. 8 deliberate breaks, each watched going red — including both HIGH findings from earlier rounds, proved closed at the HTTP boundary rather than by inspecting message text.

The suite shows 13 failures in model and api/handlers. All 13 were re-run on a pristine upstream/main tree and fail identically there (payments/leaderboard/subsidy and a missing apple_roots.pem). Zero new failures. All 10 new model regression tests and both new handler tests pass when run explicitly.

Two LOW findings are recorded rather than hidden: a comment overclaiming why the diagnostic survives a flood is corrected in the final commit, and TestConfiguredAppleRootCertificates panics the whole api/handlers binary on a missing optional config — pre-existing on upstream, but it means this branch's own status-code tests are skipped in a full-package run unless that file is present.

Deploy note

The code makes the wedge visible; the fix is to set BRINGYOUR_TRUSTED_PROXY_CIDRS to the ingress proxy's range. The beta api service has no env_file key, so it cannot currently be injected there at all.

Ryanmello07 and others added 18 commits August 22, 2026 16:20
A first POST /auth/network-create of the day, from an address that had
created one account in ten days, was answered 429 on both production
deployments. Signup was effectively down for new users.

Root cause: session.ResolveClientAddress returns the immediate TCP peer's
address when that peer is not in trustedProxyPrefixes(). The ingress proxy
reaches the api from a docker bridge address, and
BRINGYOUR_TRUSTED_PROXY_CIDRS -- which defaults to loopback only -- appears
exactly once in this repo, in its own const, and is set by nothing. So every
request on the deployment resolved to one address and every per-address
budget in the service collapsed into a single budget for the whole fleet.
The window is sliding, so it never reset.

The four changes below do not widen a single limit. Three of them stop a
legitimate user from being charged for something that is not theirs; the
fourth makes the misconfiguration impossible to sit through silently.

1. Report an unenumerated proxy instead of failing silently
   (session/client_session.go)

   A request whose peer is NOT trusted but which carries X-UR-Forwarded-For
   or X-Forwarded-For is exactly the wedged condition: something upstream
   believes it is a proxy this service trusts, and this service disagrees.
   That now emits a glog.Errorf naming the peer, the header, the current
   trusted set and BRINGYOUR_TRUSTED_PROXY_CIDRS, and saying plainly that
   every client behind the peer is sharing one rate-limit budget.

   The peer is NOT auto-trusted. Honoring a forwarding header from an
   unenumerated peer would let any client claim any source address and step
   outside every per-address limit in the service, which is a strictly worse
   bug than the one being fixed. The report is rate limited -- once per
   distinct peer over a bounded set, then at most once per minute -- because
   any client can set a forwarding header on a directly reachable api, and
   an unbounded report is both a log flood and unbounded memory. The
   interval path never stops firing, so a deployment that becomes wedged
   later cannot go quiet again.

2. The auth-attempt limiter answers 429, not 503
   (model/auth_model_attempt.go, model/rate_limit_error.go)

   maxUserAuthAttemptsError returned "503 User auth attempts exceeded
   limits." A 5xx tells every well-behaved client and SDK that the server is
   broken and the request should be retried, and every retry records another
   attempt, so the status itself consumed the remaining budget faster and
   the refusal reinforced itself. The other limiter on the same endpoint
   already returned 429. Rate limiting is client-attributable.

3. Input-validation failures no longer spend the budget
   (model/network_model.go)

   UserAuthAttempt was consumed above the terms check and above network-name
   validation. An unticked terms box, a name that was too short, or a name
   someone else already had each burned one of five slots in a five-minute
   window -- and for a signup carrying no user auth (SSO, wallet) those
   slots are shared by everyone at the client address, so one person
   fumbling a form refused strangers. The limiter now runs after input
   validation.

   This does not make abuse easier. Nothing between the top of the function
   and the limiter grants a capability that is not already available
   unauthenticated and unlimited: ValidateNetworkName is pure, and
   checkNetworkNameAvailability is the same read-only name lookup POST
   /auth/network-check already serves to anyone with no limiter at all.
   Everything that creates or mutates state stays below the limiter.
   TestValidSignupsStillSpendTheAuthBudget pins the abuse side directly: it
   interleaves a free pre-validation refusal before every well-formed
   submission and asserts the limiter still fires on the well-formed ones at
   the documented count. If the limiter moved back above validation, the
   interleaved refusals would be charged too and that count would halve, so
   the test discriminates the fix from the bug rather than passing either
   way. Making a form mistake free does not stretch an attacker's budget.

4. Honest refusals, with Retry-After
   (model/network_create_rate_limit.go, model/auth_model_attempt.go,
    router/handler_utils.go)

   The account-creation refusal read "You have reached the maximum number of
   account creations for today" to a user who had created none: the budget
   is keyed on a subnet of the client address, so the usual recipient is
   being refused for what others sharing the address did and then told it
   was their own doing. Both refusals now say the limit is scoped to the
   network address, which is what lets support tell a wrongly-refused user
   on a shared connection apart from actual abuse.

   The auth-attempt wording is chosen by scope, because it is not always
   address-wide: with no user auth the Redis key carries no identity and the
   budget really is shared, but with a user auth it is that account's. A
   blanket "address-scoped" sentence would have been false at five of the
   seven call sites.

   Both refusals carry Retry-After. RaiseHttpError reads it off the error
   through a one-method interface so model does not have to import the
   router. For account creation the value is the real remaining time on the
   window, computed in the same statement and against the same clock as the
   count.

Deliberately NOT changed:

  - the /29 IPv4 and /56 IPv6 masking in ip.go. Widening it is an
    anti-abuse policy decision, not a bug fix.
  - every limit value.
  - the auth-attempt sliding window never resetting under retry. A rejected
    attempt is still recorded, so a client that retries keeps its own window
    full. That is real, but it is the limiter's protection against a
    hammering caller; removing it would weaken it. Retry-After now gives
    such a client the wait it needs to actually recover.
  - the 500-with-a-plain-body convention for validation refusals, which
    means NetworkCreateResult.Error is unreachable over HTTP. Pinned
    unchanged by TestNetworkCreateValidationRefusalIsNotReportedAsSuccess.
  - the email/UserAuth branch never calling SetUserAuthAttemptSuccess.

Tests: session/client_address_ingress_test.go,
model/network_create_rate_limit_regression_test.go,
api/handlers/network_create_status_regression_test.go. The tests that
pinned the wrongful behaviour now pin the fixed behaviour and keep a note of
what the old behaviour was and why it was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
The previous commit made an unenumerated ingress proxy loud: a request whose
TCP peer is not in BRINGYOUR_TRUSTED_PROXY_CIDRS but which carries a forwarding
header now tells the operator to add that peer's subnet. Review found that the
remedy it printed was a way to take the api down.

ResolveClientAddress's TRUSTED branch required X-UR-Forwarded-For as ip:port,
or X-Forwarded-For paired with X-Forwarded-Source-Port and single-hop, and
returned an error for anything else. router.wrap and router.wrapWithInput both
answer a session construction failure with HTTP 500, on every endpoint. Stock
nginx (proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for), ALB,
CloudFront and Cloudflare all send a bare X-Forwarded-For with no source port,
and $proxy_add_x_forwarded_for APPENDS, so a second hop adds a comma that was
also rejected. The deployment shape most likely to trigger the new report was
exactly the one the remedy broke.

1. The trusted branch reads what real proxies send (session/client_session.go)

   A forwarding header is now parsed as a chain and walked RIGHT TO LEFT,
   skipping entries that are themselves trusted proxies; the first entry that
   is not a trusted proxy is the client. Entries may be a bare ip, ip:port or
   [v6]:port. X-Forwarded-Source-Port still pairs with a single-hop
   X-Forwarded-For exactly as before; on a longer chain the port belongs to
   whichever hop wrote it and is dropped rather than guessed, so the address is
   resolved with port 0. Every per-address limiter buckets on the ip alone
   (server.ClientIpHash), so an unknown port costs the client nothing.

   The direction is the security property. A proxy appends what it observed, so
   everything to the LEFT of the entry a trusted hop wrote is text the client
   supplied. Reading left to right -- "the original client" -- would let every
   client behind the proxy pick its own rate-limit bucket, which is strictly
   worse than the shared-budget bug being fixed. Repeated header field lines
   are joined before the walk, because Header.Get returns only the first line
   and a proxy that appends a second line is legal; reading only the first
   would hand back precisely the client's own value.

   Nothing that used to resolve resolves differently. Every shape newly
   accepted returned an error before, and an error is a 500, so there is no
   working deployment for the looser contract to take anything away from.
   TestEveryLoosenedShapeUsedToBeAnHttp500 checks that claim against a verbatim
   copy of the old implementation rather than asserting it in a comment.

2. An unreadable header degrades instead of 500ing, and says so

   Anything still unreadable falls back to the peer address. That cannot be a
   wrong attribution -- the peer is where the packets came from, the most
   restrictive answer available -- but it silently reinstates the fleet-wide
   budget collapse, so reportUnusableForwardingHeader names the trusted peer
   and its own remedy. The header VALUE is never logged: on any proxy that
   forwards client headers it is client-controlled.

3. The report's remedy is now the whole remedy

   It states what the proxy must send once enumerated and, more importantly,
   what it must not do: forward a client-supplied value through unchanged.

4. The report guard is under test, and is per-peer

   The report tests stubbed reportUnenumeratedProxy with a copy that
   re-implemented the rate-limit guard, so the production guard could be
   deleted with the suite still green. They now stub only glogErrorf, putting
   ResolveClientAddress -> the real report -> the real guard -> the log under
   one seam.

   The guard itself was one global "last reported at" instant shared by every
   peer, over a set that never evicted: 1024 distinct peers and the per-peer
   path was gone for the process lifetime, and whoever called first in each
   interval took the only token, so a caller who could reach the api directly
   could keep the genuinely wedged proxy out of the log. It is now
   map[(peer, condition)]time.Time, written only when a report is actually
   emitted, pruned when it reaches its cap, keyed on the unmapped address.

5. Impl tagging uses %w (router/handler_utils.go)

   WrapRequireAuth, WrapRequireClient and WrapNoAuth tagged impl errors with
   %s, which flattens the error and breaks the errors.As that RaiseHttpError
   uses to find Retry-After. The status survived (the regex peels the tag), so
   the symptom would have been an invisible missing header. No rate limit is
   reached through those wrappers today; this is for the next one.

6. The comment justifying the limiter reorder is corrected
   (model/network_model.go)

   It claimed checkNetworkNameAvailability is the same lookup
   /auth/network-check already serves unmetered. It is a superset: same fuzzy
   search plus an exact SELECT in a transaction. The reorder is still safe --
   nothing above the limiter writes -- but the comment is load-bearing for
   anyone moving code across the limiter later and it overstated its case.

No limit value, no /29 or /56 masking, and no auto-trust of an unenumerated
peer is touched. Abuse does not get cheaper: relative to a WEDGED deployment
per-client attribution does allow more accounts (5/24h per /29 rather than
5/24h fleet-wide), but that is nominal policy, and the wedged state was a bug
that refused legitimate users. Degrading to the peer address can only tighten.
Port 0 makes the wallet-auth-challenge key coarser, never finer.
The %w that keeps Retry-After reachable through errors.As was written three
times. A test that constructs its own fmt.Errorf("[impl]%w", ...) passes no
matter what the wrappers do -- the first version of the test for this did
exactly that and stayed green when the production tagging was reverted to %s.
One function, driven end to end through WrapNoAuth and directly for the two
wrappers that need a JWT, actually fails.
proxyIsTrusted unmaps and server.ClientIpHashForAddr does not, so a peer left
in its [::ffff:a.b.c.d] form buckets under the ipv6 /56 rule instead of the
ipv4 /29 one and holds a second, separate per-address budget.
Two gaps in the previous commit.

The prune walks the whole suppression map holding the process-global report
mutex, on a path any caller who can reach the api directly can trigger. While
the map is full -- which is precisely the state a caller cycling source
addresses creates -- it ran on every request, turning the flood guard into an
O(cap) critical section aimed at whoever filled it. It now runs at most once
per interval. That is safe not because a more frequent scan would find nothing
(entries age out continuously) but because the prune is best-effort
reclamation: a lingering entry delays reuse of one slot, memory is bounded by
the cap either way, and the global-token tier keeps the report firing while a
prune is deferred.

The branch where every hop in the chain is itself a trusted proxy -- ordinary
internal traffic between two enumerated components -- returns the peer address
and emits no report, and nothing reached it. Conflating it with the
unreadable-header case would fill an operator's log with alerts about traffic
working exactly as intended.
TestVerifySourceIpPrecedence case 3 asserted that a trusted proxy sending a
bare X-Forwarded-For is REJECTED, on the reasoning that rejecting is safer than
silently re-attributing the request to the proxy. The premise was right; the
conclusion was not. The rejection is an error, and router.wrap turns that into
HTTP 500 on every endpoint, so it meant the api was down for any deployment
that enumerated a stock nginx.

Attribution is preserved by reading the client out of the header instead. The
case now pins that, plus the two properties it was really protecting: an
unreadable header falls back to the immediate peer rather than to anything the
caller chose, and a chain resolves to the entry the trusted hop appended, not
the entry a client prepended.
…794cf

CORRECTION to ac794cf, which said "Nothing that used to resolve resolves
differently." That is not true, and the exception is the one place this work
narrows attribution rather than widening it.

The previous reader never compared a forwarded VALUE to the trusted set: it
took X-UR-Forwarded-For, or X-Forwarded-For + X-Forwarded-Source-Port, and
returned it. The new reader walks the chain right to left skipping entries that
are themselves inside an enumerated CIDR -- which is what finds the client
behind two hops and what stops a client prepending an address of its own. The
cost is that a forwarded address INSIDE an enumerated range is now read as a
proxy hop. An operator who enumerates 172.16.0.0/12 because their docker bridge
peer is 172.18.0.7 therefore takes the individual bucket away from any real
caller arriving from inside 172.16.0.0/12.

The direction is conservative -- those callers share the proxy's address, they
are never attributed to anything a caller chose -- but it is the shared-budget
failure this work exists to remove, on a narrow path, so it is now said out
loud in three places rather than left to be discovered:

  - reportUnenumeratedProxy tells the operator to enumerate the NARROWEST range
    that contains only proxies and says what an address inside one costs;
  - the !found branch's comment states both readings of an all-enumerated
    chain and why neither is reported, instead of asserting the benign one;
  - TestForwardedAddressInsideAnEnumeratedCidrSharesTheProxyBucket pins the
    trade against a verbatim replica of the old branch, and
    TestEveryLoosenedShapeUsedToBeAnHttp500 now says in its own comment that
    its argument covers the loosening only.

Keeping the skip rather than special-casing a single-hop chain is deliberate:
a rule that takes a trusted value when the chain has one entry and skips it
when the chain has two is harder to reason about and does not remove the
multi-hop case, where skipping is what makes the walk correct.
… client pick one

ResolveClientAddress read the FIRST forwarding header it found and ignored the
rest, and forwardingHeaders lists X-UR-Forwarded-For first. A proxy overwrites
the header IT sets and passes every other client header through untouched, so
on any deployment whose proxy owns X-Forwarded-For -- Caddy, nginx's documented
recipe, ALB, CloudFront, Cloudflare -- a client that sent its own
X-UR-Forwarded-For beat the address the proxy had vouched for and chose its own
rate-limit bucket, stepping outside the 5-per-24h account creation limit and
the 5-per-5min auth attempt limit.

The capability predates this branch, but this branch is what makes it matter:
before it, enumerating a stock proxy answered HTTP 500 on every endpoint, so
few operators would have got as far as reaching it.

Preferring X-Forwarded-For instead is rejected, with evidence: BOTH ownership
shapes exist in this project. The warp load balancer forces
X-UR-Forwarded-For = $remote_addr:$remote_port (controller/verify_controller.go
:139), while the beta Caddyfile has to strip X-UR-Forwarded-For by hand
(header_up -X-UR-Forwarded-For) because Caddy owns X-Forwarded-For. A static
preference in either direction is the forgery in one of the two shapes.

So neither header is declared authentic. Every present forwarding header is
read; when two of them name two DIFFERENT client addresses, NEITHER is honored
and the request is attributed to the peer -- the most restrictive answer
available, never an address of the client's choosing -- under a report of its
own, because that fallback is the shared-budget collapse this file exists to
prevent.

Two deliberate limits on that rule:

- only the ADDRESS is compared. A proxy that sets both headers correctly sends
  them in different forms (ip:port in one, a bare ip whose port reads as 0 in
  the other), so requiring the ports to match would call a working deployment
  a conflict and take its client address and port away for nothing. Port
  handling is therefore untouched: the first present header decides it.
- the first PRESENT header still decides the value, not the first header that
  named somebody. Stepping past a header that names only enumerated hops is a
  caller inside the trusted range getting its own X-Forwarded-For honored.

A malformed header still aborts the whole read, whichever header it is: the
unreadable one can be the proxy's and the readable one the client's.

Abuse is not cheaper. A client that reaches the new fallback lands on the
proxy's shared bucket rather than one it named -- strictly more restrictive. It
is a NEW ability on a warp-shaped deployment (X-UR-Forwarded-For was picked
first, so a garbage X-Forwarded-For was never read) and it buys nothing:
legitimate traffic resolves to real client addresses, so poisoning the peer
bucket refuses only other poisoners. The report is bounded to one line per peer
per condition per minute by the existing suppression, and the header VALUES are
still never logged -- on this path both are client-influenced by definition, so
only the two header NAMES, which come from the forwardingHeaders constants, are
interpolated into a constant format string.

Every operator-facing report now states the COMPLETE header contract --
OVERWRITE OR STRIP ALL THREE of X-Forwarded-For, X-UR-Forwarded-For and
X-Forwarded-Source-Port -- rather than only the header that triggered it. An
operator who fixes the one header a report named and passes the others through
has closed nothing. This is also what closes the client-settable half of the
X-Forwarded-Source-Port issue: that header sets the port component of the
wallet-auth-challenge threshold key.

TestUrForwardedForChainIsNotReadAsASingleHop used to end by pinning "X-UR wins
over X-Forwarded-For when both are present". That pin is deliberately INVERTED
here, not quietly weakened: it was the forgery wearing the other header name.
The shapes where the two headers AGREE -- what a correctly configured proxy
sends -- still resolve byte for byte as they did, which
TestProxySettingBothForwardingHeadersConsistentlyIsUnchanged pins.
…se them

Comments only, no behaviour change. Both are things review found that are
correct as written but change what a bucket means on the day this ships, and
neither was stated where the code does it.

1. parseRequestAddress's Unmap is a loosening at deploy. netip treats a 4-in-6
   address as ipv6 (Is4 is false for it), so server.ClientIpHashForAddr masks
   such a client under the /56 rule -- and the first 7 bytes of the 4-in-6 form
   are zero, so EVERY 4-in-6 client on a deployment hashes to one bucket and
   shares one account-creation budget. After the Unmap they split into their
   real per-/29 buckets, so each gets a fresh budget once. Same
   wrongly-collapsed-bucket-un-collapsed as the rest of the branch, low
   reachability (Go renders ipv4 peers in ipv4 form), but disclosed rather than
   discovered.

2. the wallet-auth-challenge threshold keys on client_address_port as well as
   the address hash. The port is client-influenced wherever a trusted proxy
   passes X-Forwarded-Source-Port or X-UR-Forwarded-For through -- the remedy
   is the proxy's, and it is what the reports now state -- and it MOVES with
   the deployment: on a proxy that sends a bare X-Forwarded-For the port is 0
   for everyone, so the key becomes per-/29 instead of per-connection. That
   makes the threshold bind for the first time and starts sharing five failed
   challenges across a /29.

   Deliberately not repaired. Dropping the port from that key closes the first
   half by making the second universal -- a shared budget on every deployment,
   which is the wrongful-refusal failure this branch exists to remove, in a
   limiter with no coverage here. Left alone on purpose, not by omission.
Caught by a deliberate break: gutting proxyForwardingHeaderContract down to two
of the three headers left every assertion in the previous commit passing. Both
reports already name all three headers incidentally -- "optionally with
X-Forwarded-Source-Port", "or X-UR-Forwarded-For as ip:port" -- so
Contains(report, "X-Forwarded-Source-Port") was satisfied by prose that says
the opposite of what the assertion is for.

The tests now pin the enumeration itself, "X-Forwarded-For, X-UR-Forwarded-For
and X-Forwarded-Source-Port", in all three reports. With the same break
applied, all three fail:

  the misconfiguration report does not mention "X-Forwarded-For,
  X-UR-Forwarded-For and X-Forwarded-Source-Port"
  the conflicting-header report does not mention ...
  the unusable-header report does not mention ...
…ty element

Two follow-ups the full suite and a break turned up.

TestVerifySourceIpPrecedence case 1 asserted "X-UR-Forwarded-For wins over
everything" with the two headers naming two DIFFERENT clients, so it failed
against the conflict rule -- and it was the same pin, in another package, for
the same reason: on a deployment whose proxy owns X-Forwarded-For, the header
the CLIENT can set is X-UR-Forwarded-For, and /verify egress identity must not
be a value the caller picked. The case is split: 1 keeps the precedence
/verify actually depends on (X-UR-Forwarded-For's ip:port beats the bare form
plus X-Forwarded-Source-Port for the SAME client, which is what a proxy setting
both correctly sends), and 1b pins the peer fallback for the disagreeing pair.

  observed before the split:
  verify_controller_test.go:168: X-UR-Forwarded-For must win, got "10.9.9.9:5555"

TestAnEmptyChainElementIsNotSkipped pins the trailing-comma decision review
raised as an open question. "203.0.113.9," degrades to the peer with a loud
report rather than skipping the empty element, and that stays: the shape the
tolerance would rescue is indistinguishable from the shape it would break --
where the header is wholly client-supplied, "6.6.6.6," would resolve to
6.6.6.6 -- and no proxy in this deployment's set emits an empty element. Both
halves are asserted, so deleting the test is the deliberate act if a proxy that
needs it ever turns up.
…hat does

A client behind a trusted proxy could still choose its own client address, and
therefore its own rate-limit bucket, on either deployment shape.

    trusted = 172.16.0.0/12, peer 172.18.0.7:52344
    X-Forwarded-For:    172.20.5.5    (the proxy's, an address inside the range)
    X-UR-Forwarded-For: 6.6.6.6:1234  (the client's)
    => resolved to 6.6.6.6:1234

conflictingCandidates skipped candidates with found == false as "names no
client, so it cannot disagree", while thirty lines later the same condition on
candidates[0] meant "refuse everything". So the guard covered one ordering and
left its mirror open -- and which header a proxy owns differs by product, so a
rule that holds for one ordering holds for neither deployment.

found == false now means the same thing in both places: a PRESENT header whose
every entry is inside an enumerated CIDR is a claim that the client is a
trusted hop, it disagrees with any header naming a real client, and the request
falls back to the peer address. candidateNamesNoClient checks EVERY candidate,
not just the first.

Why this is safe in the direction it closes: the warp sidecar writes
X-UR-Forwarded-For as $remote_addr:$remote_port, the peer it sees. If X-UR
names a real client then that sidecar is the edge, so no inner enumerated hop
could have written an all-enumerated X-Forwarded-For -- that header came from
the client. The mirror direction (warp nginx behind an enumerated CDN) already
resolved to the peer via the first-candidate guard and loses nothing.

The shape is refused SILENTLY, not reported. Behind a trusted proxy the peer is
one address for every client, so the (peer, condition) suppression slot is
shared; a client can produce a no-claim header whenever it likes, so reporting
this would hand it a way to take that slot every interval and silence the
genuine conflicting-headers report. With two entries in forwardingHeaders, a
found == false candidate means at most one candidate is found == true, so
conflictingCandidates provably cannot fire for anything the new rule catches --
no currently-reporting shape changes.

Does not make abuse cheaper. The only new outcome is the peer address, which is
where the packets came from and the most restrictive answer available. A client
that wants to demote itself onto the peer bucket can already do it today with
X-Forwarded-For: garbage (a malformed header aborts the whole read), so this
adds no capability; it removes one.

Tests, each watched failing against the unfixed resolver:
  session TestASecondForwardingHeaderCannotChooseTheBucket -- three new shapes
    (both orderings + a two-hop enumerated chain), with wantReports pinning
    that they stay quiet
  session TestForwardedAddressInsideAnEnumeratedCidrSharesTheProxyBucket --
    the two-header block, because "shares the proxy's bucket" is only worth
    something when the caller also sends the other header
  router  TestSecondForwardingHeaderDoesNotReachTheHandler -- the same two
    shapes at the HTTP boundary, where the ClientAddress handed to the impl is
    what every per-address limit keys on

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
… raw ip

The report suppression key unmapped the peer but did not mask it, so a caller
bought a fresh report slot with every source address it owned. Measured on the
shipped code: one unenumerated-proxy report is 1754 bytes, and a caller
rotating addresses out of ONE ipv6 /64 -- an ordinary residential allocation --
sustained the full proxyReportedMax cap.

    single ipv6 /64, 5000 fresh addresses per simulated minute
      before: [1024 1024 1024 1024] lines/min = 2.59 GB/day, map at 1024 entries
      after:  [1 1 1 1] lines/min, map at 1 entry

The key is now the peer NETWORK, using the prefixes server.ClientIpHashForAddr
already uses to decide what counts as one client (/29 v4, /56 v6). ip.go is
untouched -- this reuses its answer, it does not change it. A caller that
cannot buy extra rate-limit budget by rotating inside its /29 or /56 can no
longer buy extra log volume with it either.

Coarser would be worse than the flood, so the test pins both directions: eight
addresses in one /29 are one line, and the ADJACENT /29 is still named. Two
genuinely different unenumerated proxies silencing each other would take away
the report an operator has to act on.

The liveness property is pinned separately and unchanged:
TestGenuineReportSurvivesAFloodOfDistinctPeers drives 4000 distinct /29s per
interval past the wedged proxy and requires all 5 of 5 of its reports.

Does not make abuse cheaper. It removes report slots, never adds any: every
peer that was suppressed before is still suppressed, and some that were not now
are. Nothing about address resolution or any rate-limit budget is touched.

Also states, at the format strings, that printing the peer's full unhashed
address is deliberate -- it is the one place this service writes a client
address in plaintext, and the address IS the remedy, since "enumerate this
CIDR" is not actionable from a hash. The header VALUE, the part a client
controls, is still never logged.

NOT done, deliberately: no global lines-per-interval ceiling. A caller holding
1024 distinct /29s (8192 ipv4 addresses) still reaches 626 lines/min in the
same harness. A true cap needs the ceiling on the already-seen branch, which is
exactly the path that keeps the wedged-proxy report firing every interval, so
it would trade a verified liveness property for log volume.

Test: session TestReportSlotsAreNotBoughtByRotatingSourceAddresses, watched
failing against the unbucketed key with "every address in one ipv4 /29 was
reported separately (8 lines)".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
CheckNetworkCreateRateLimit computes the hint in SQL and the comment above it
promises "the real remaining time on the window: the oldest attempt still
counted expires then." Nothing asserted the value.
TestAccountLimitRefusalIsHonestAboutItsScope only required 0 < seconds <=
window, api/handlers' assertRateLimitBody only required that the header parses
and is positive, and the router tests use a test-local error type rather than
model.rateLimitError -- so a flat restatement of the window, or arithmetic
broken by the timestamp/timestamptz mix in that expression, passed the whole
suite.

The new test backdates a full budget of attempts by 20 hours and requires the
hint to follow them: ~4h, not the flat 86400. The two candidate answers are 20
hours apart, so the 15-minute tolerance covers clock skew between the test
process and postgres without coming near the wrong answer.

The behaviour was already correct -- this is a coverage gap, not a defect, and
the blast radius of a future regression here is bounded by the existing
[1, window] clamp: a wrong hint, never an outage. It is worth pinning because
the failure is symptomless from the server's side and lands entirely on the
client, which either waits a day it did not have to or hammers the endpoint
because the hint is obviously wrong.

Does not make abuse cheaper: test-only, and it asserts a SMALLER hint is
correct only when the attempts really are old.

Watched failing against `INTERVAL '1 seconds' * $2` flattened to the full
window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
TestUnenumeratedProxyReportIsRateLimited proves a second peer gets its own
report line using 172.18.0.7 and 172.18.0.8. Now that the suppression key is a
peer network, those two are distinct only because .7 is the last address of
172.18.0.0/29 and .8 is the first of 172.18.0.8/29 -- move either and the
assertion silently becomes one peer reported twice, which the interval already
forbids, and it passes for the wrong reason.

Comment only; the invariant is pinned deliberately in
TestReportSlotsAreNotBoughtByRotatingSourceAddresses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
TestGenuineReportSurvivesAFloodOfDistinctPeers drove 4000 iterations per
interval but only ~500 distinct /29s, because the counter varied bits the /29
mask keeps rather than the ones it does not -- so the bucketing the test sits
next to was doing part of the work the flood was supposed to do.

{n>>13, n>>5, n<<3} is the big-endian encoding of n into the 29 network bits,
so every iteration is its own /29: four thousand distinct networks per
interval, four times the map's cap. The assertion is unchanged and still holds
at 5 of 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtgqtCmKJRXdsQ5ktiqwkg
The comment claimed the wedged proxy 'wins the token on nearly every pass' and
cited TestGenuineReportSurvivesAFloodOfDistinctPeers as proof. Review found both
halves false: deleting the entire global-token branch left that test green, so it
never reaches the tier it was cited for, and re-running the scenario with the
flood arriving first after each interval boundary named the wedged proxy 0 times
out of 5, not 5.

The real mechanism is the seen-branch refreshing the key on every request, ahead
of the prune. The prune evicts at exactly one interval, so a boundary-timed flood
can starve the diagnostic -- at a cost of >=1024 sustained distinct /29s or /56s,
to win fewer log lines. Never a wrong address, never a budget, never a spoof.

Recorded rather than fixed: tightening the prune to buy the property back trades
a real bound for a diagnostic nicety. A comment asserting a protection that does
not hold is worse than one that states the limit.
@Ryanmello07
Ryanmello07 merged commit 0e08f47 into urnetwork:main Aug 23, 2026
1 check passed
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