Bucket IPv6 rate-limit keys by /64 (closes #125) #162

Merged
clawbot merged 1 commits from issue-125-ipv6-prefix-buckets into next 2026-08-17 23:52:16 +02:00
Collaborator

Closes #125 — option 1 as ruled on the issue. IPv6 buckets by /64, IPv4 unchanged, no new configuration surface.

Why

Rate-limit keys were per-address, i.e. per /128 for IPv6. A routed /64 is the normal residential and mobile IPv6 allocation, so a client could rotate source addresses inside its own prefix and mint a fresh bucket per request — evading the limiters at the network layer with no spoofing and nothing to detect. #88 closed the header half of this control; this is the network half.

What changed

internal/middleware/ratelimit.go gains bucketKey(netip.Addr) string, applied at the three return points of clientKey. IPv4 keys on the full address; IPv6 keys on addr.Prefix(64). Stdlib net/netip only, which the file already used — no new dependency, no hand-rolled byte masking.

All three limiters share the key function — confirmed in the code

Every limiter instance is constructed with httprate.WithKeyFuncs(m.rateLimitKey), and rateLimitKey delegates to clientKey:

  • LoginRateLimit and PasswordChangeRateLimit both build through postRateLimit, which passes m.rateLimitKey.
  • ReceiverRateLimit builds two limiters, and both pass m.rateLimitKey: the per-entrypoint one as WithKeyFuncs(m.rateLimitKey, httprate.KeyByEndpoint), the aggregate one as WithKeyFuncs(m.rateLimitKey).

That is four limiter instances across the three limiters, and m.rateLimitKey is the only key function any of them names — there is no other call site.

Both branches of the key function

clientKey has two live branches: the direct-peer branch, and the trusted-proxy branch that takes the client address out of X-Forwarded-For. README.md requires a production deployment to run behind a reverse proxy with TRUSTED_PROXIES set, so the forwarded branch is the one that carries production traffic. Both are masked, and both are now pinned by tests that fail when their own branch alone is reverted.

IPv4-mapped addresses

::ffff:1.2.3.4 keys as 1.2.3.4, never masked. Mapped form all sits inside ::ffff:0:0/96, so masking it to a /64 would collapse every IPv4 client behind a proxy that emits mapped form into one shared bucket. clientKey already ran addresses through normalizeAddr (which unmaps); bucketKey calls Unmap() again so the property holds of the key function itself rather than depending on its caller. Covered on both branches.

Malformed addresses

Unchanged behaviour, deliberately preserved and now tested. An unparseable RemoteAddr returns the raw value, which is the most specific identity left — distinct values stay in distinct buckets, so this path cannot silently collapse unrelated clients together. (A Unix-socket listener, where every peer carries the same RemoteAddr, still shares one bucket; that is the fail-closed direction and predates this change.)

Addr.Prefix cannot error here: it is fallible only for a negative bit count, over 32 bits on an IPv4 address, or over 128 on IPv6, and the count is the constant 64 with the IPv4 case already returned above. The error is therefore discarded rather than branched on, with a comment stating why. The zero Addr does not error either — it yields the zero Prefix — and neither call site can produce one, since both parse the address first.

Key collisions between families

Not possible. An IPv4 key is a bare dotted quad; an IPv6 key always carries a /64 suffix. TestRateLimitKey_FamiliesDoNotCollide asserts that structurally: every IPv4 key must parse as a bare netip.Addr and every IPv6 key as a netip.Prefix of 64 bits, with the two key sets disjoint.

Tests

internal/middleware/ratelimit_test.go.

Direct-peer branch:

  • TestRateLimitKey_IPv6BucketsByPrefix — table over the key function: two addresses in the same /64 key identically, a different /64 keys differently, two distinct IPv4 addresses key differently, and IPv4-mapped keys as its plain IPv4 form.
  • TestLoginRateLimit_IPv6SharesBucketWithinSlash64 — the behavioural regression test, driving a real limiter: rotating the source address on every request inside one /64 still gets a 429.
  • TestLoginRateLimit_IPv6IndependentAcrossSlash64 — a different /64 keeps its own limit.
  • TestLoginRateLimit_IPv4IndependentPerAddress — no IPv4 regression; neighbouring addresses hold separate buckets.

Trusted-proxy forwarded branch, i.e. the production shape:

  • TestRateLimitKey_ForwardedIPv6BucketsByPrefix — same table, but with TRUSTED_PROXIES set to 10.0.0.0/8, the peer inside it, and the client named in X-Forwarded-For. Same-/64, different-/64, IPv4 and IPv4-mapped cases; a mapping proxy is exactly where mapped form shows up.
  • TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64 — behavioural: rotating the forwarded address inside one /64 still gets a 429.
  • TestLoginRateLimit_ForwardedIPv6IndependentAcrossSlash64 — a different forwarded /64 keeps its own limit.

Plus TestRateLimitKey_FamiliesDoNotCollide and TestRateLimitKey_UnparseablePeerKeepsDistinctBuckets.

Test-only refactor: the IPv6 sample addresses, their expected /64 keys, and the trusted-proxy CIDR and peer are now named constants (clientIPv6, clientIPv6Same, clientIPv6Other, clientBucketV6, clientOtherBucketV6, trustedProxyCIDR, trustedPeer), substituted byte-identically at their existing use sites: six for the CIDR, six for the peer, and six in the direct-peer IPv6 table. No assertion changed. This keeps the file consistent now that the same values are used on both branches, and keeps goconst off it.

Mutation verification

Both mutations were run, and reverted afterwards.

Forwarded branch alone. internal/middleware/ratelimit.go:213 reverted from return bucketKey(addr) to return addr.String(), with ipv6BucketBits left at 64. docker build --target builder --no-cache-filter=builder . exits 1:

--- FAIL: TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64 (0.00s)
        Error:      Not equal:
                    expected: 429
                    actual  : 200
        Messages:   rotating forwarded source addresses inside one routed /64 must not mint fresh buckets
--- FAIL: TestRateLimitKey_ForwardedIPv6BucketsByPrefix (0.00s)
    --- PASS: TestRateLimitKey_ForwardedIPv6BucketsByPrefix/ipv4 (0.00s)
    --- PASS: TestRateLimitKey_ForwardedIPv6BucketsByPrefix/ipv4-mapped (0.00s)
    --- FAIL: TestRateLimitKey_ForwardedIPv6BucketsByPrefix/ipv6 (0.01s)
                    expected: "2001:db8:1:2::/64"
                    actual  : "2001:db8:1:2:3:4:5:6"
    --- FAIL: TestRateLimitKey_ForwardedIPv6BucketsByPrefix/ipv6-other-in-same-64 (0.00s)
                    expected: "2001:db8:1:2::/64"
                    actual  : "2001:db8:1:2:aaaa:bbbb:cccc:dddd"
    --- FAIL: TestRateLimitKey_ForwardedIPv6BucketsByPrefix/ipv6-different-64 (0.00s)
                    expected: "2001:db8:1:3::/64"
                    actual  : "2001:db8:1:3::1"
FAIL  sneak.berlin/go/webhooker/internal/middleware  0.092s

The direct-peer tests correctly all still pass under it, which is the point: this mutation is only visible to the new forwarded tests. The IPv4 and IPv4-mapped forwarded cases also still pass, since neither is masked.

Bucket width. ipv6BucketBits flipped from 64 to 128, make test:

--- FAIL: TestLoginRateLimit_IPv6SharesBucketWithinSlash64
--- FAIL: TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64
--- FAIL: TestRateLimitKey_FamiliesDoNotCollide
--- FAIL: TestRateLimitKey_ForwardedIPv6BucketsByPrefix
--- FAIL: TestRateLimitKey_IPv6BucketsByPrefix

TestRateLimitKey_FamiliesDoNotCollide is in that list because it now asserts the shape of each key against a bucket width restated in the test rather than imported from the package under test. In its previous single-NotEqual form it survived this mutation.

README

Surgical edit to the Rate Limiting section only — one inserted passage in the paragraph describing the shared key function, which previously implied per-address keying. It now states the per-family bucketing, why /64 is the unit, the cost (distinct clients inside one /64 share a bucket), and the IPv4-mapped rule. No other part of README.md was touched, and no reflow, since other units are editing the file concurrently. make fmt is Go-only in this repo and formats no markdown; the inserted lines are hand-wrapped to the file's existing width.

Verification

Rebased onto next at c378690; the gates below were run after that rebase.

docker build --no-cache-filter=lint --no-cache-filter=builder . — exit 0, checks demonstrably executed rather than replayed:

  • lint stage RUN make lint DONE 55.9s, 0 issues.
  • builder stage RUN make test DONE 56.8s, zero (cached) markers anywhere in the log; real per-package durations, e.g. ok sneak.berlin/go/webhooker/internal/middleware 1.109s, ok .../internal/delivery 4.216s, ok .../internal/handlers 4.097s.
  • make fmt-check clean.

make check runs those same three targets and also exits 0 on the host, run with an isolated GOLANGCI_LINT_CACHE so no shared cache could contribute; the Docker run above is the authoritative lint result.

Not in this PR: golangci-lint emits a deprecation warning for the gomodguard linter (replaced by gomodguard_v2 in v2.12.0). Pre-existing and unrelated.

Also not in this PR, and filed rather than fixed here: an empty RemoteAddr keys as "", so all such peers share one bucket. Pre-existing and fail-closed. #168

Closes https://git.eeqj.de/sneak/webhooker/issues/125 — option 1 as ruled on the issue. IPv6 buckets by `/64`, IPv4 unchanged, no new configuration surface. ## Why Rate-limit keys were per-address, i.e. per `/128` for IPv6. A routed `/64` is the normal residential and mobile IPv6 allocation, so a client could rotate source addresses inside its own prefix and mint a fresh bucket per request — evading the limiters at the network layer with no spoofing and nothing to detect. https://git.eeqj.de/sneak/webhooker/issues/88 closed the header half of this control; this is the network half. ## What changed `internal/middleware/ratelimit.go` gains `bucketKey(netip.Addr) string`, applied at the three return points of `clientKey`. IPv4 keys on the full address; IPv6 keys on `addr.Prefix(64)`. Stdlib `net/netip` only, which the file already used — no new dependency, no hand-rolled byte masking. ### All three limiters share the key function — confirmed in the code Every limiter instance is constructed with `httprate.WithKeyFuncs(m.rateLimitKey)`, and `rateLimitKey` delegates to `clientKey`: - `LoginRateLimit` and `PasswordChangeRateLimit` both build through `postRateLimit`, which passes `m.rateLimitKey`. - `ReceiverRateLimit` builds two limiters, and both pass `m.rateLimitKey`: the per-entrypoint one as `WithKeyFuncs(m.rateLimitKey, httprate.KeyByEndpoint)`, the aggregate one as `WithKeyFuncs(m.rateLimitKey)`. That is four limiter instances across the three limiters, and `m.rateLimitKey` is the only key function any of them names — there is no other call site. ### Both branches of the key function `clientKey` has two live branches: the direct-peer branch, and the trusted-proxy branch that takes the client address out of `X-Forwarded-For`. `README.md` requires a production deployment to run behind a reverse proxy with `TRUSTED_PROXIES` set, so the forwarded branch is the one that carries production traffic. Both are masked, and both are now pinned by tests that fail when their own branch alone is reverted. ### IPv4-mapped addresses `::ffff:1.2.3.4` keys as `1.2.3.4`, never masked. Mapped form all sits inside `::ffff:0:0/96`, so masking it to a `/64` would collapse every IPv4 client behind a proxy that emits mapped form into one shared bucket. `clientKey` already ran addresses through `normalizeAddr` (which unmaps); `bucketKey` calls `Unmap()` again so the property holds of the key function itself rather than depending on its caller. Covered on both branches. ### Malformed addresses Unchanged behaviour, deliberately preserved and now tested. An unparseable `RemoteAddr` returns the raw value, which is the most specific identity left — distinct values stay in distinct buckets, so this path cannot silently collapse unrelated clients together. (A Unix-socket listener, where every peer carries the same `RemoteAddr`, still shares one bucket; that is the fail-closed direction and predates this change.) `Addr.Prefix` cannot error here: it is fallible only for a negative bit count, over 32 bits on an IPv4 address, or over 128 on IPv6, and the count is the constant 64 with the IPv4 case already returned above. The error is therefore discarded rather than branched on, with a comment stating why. The zero `Addr` does not error either — it yields the zero `Prefix` — and neither call site can produce one, since both parse the address first. ### Key collisions between families Not possible. An IPv4 key is a bare dotted quad; an IPv6 key always carries a `/64` suffix. `TestRateLimitKey_FamiliesDoNotCollide` asserts that structurally: every IPv4 key must parse as a bare `netip.Addr` and every IPv6 key as a `netip.Prefix` of 64 bits, with the two key sets disjoint. ## Tests `internal/middleware/ratelimit_test.go`. Direct-peer branch: - `TestRateLimitKey_IPv6BucketsByPrefix` — table over the key function: two addresses in the same `/64` key identically, a different `/64` keys differently, two distinct IPv4 addresses key differently, and IPv4-mapped keys as its plain IPv4 form. - `TestLoginRateLimit_IPv6SharesBucketWithinSlash64` — the behavioural regression test, driving a real limiter: rotating the source address on every request inside one `/64` still gets a 429. - `TestLoginRateLimit_IPv6IndependentAcrossSlash64` — a different `/64` keeps its own limit. - `TestLoginRateLimit_IPv4IndependentPerAddress` — no IPv4 regression; neighbouring addresses hold separate buckets. Trusted-proxy forwarded branch, i.e. the production shape: - `TestRateLimitKey_ForwardedIPv6BucketsByPrefix` — same table, but with `TRUSTED_PROXIES` set to `10.0.0.0/8`, the peer inside it, and the client named in `X-Forwarded-For`. Same-`/64`, different-`/64`, IPv4 and IPv4-mapped cases; a mapping proxy is exactly where mapped form shows up. - `TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64` — behavioural: rotating the forwarded address inside one `/64` still gets a 429. - `TestLoginRateLimit_ForwardedIPv6IndependentAcrossSlash64` — a different forwarded `/64` keeps its own limit. Plus `TestRateLimitKey_FamiliesDoNotCollide` and `TestRateLimitKey_UnparseablePeerKeepsDistinctBuckets`. Test-only refactor: the IPv6 sample addresses, their expected `/64` keys, and the trusted-proxy CIDR and peer are now named constants (`clientIPv6`, `clientIPv6Same`, `clientIPv6Other`, `clientBucketV6`, `clientOtherBucketV6`, `trustedProxyCIDR`, `trustedPeer`), substituted byte-identically at their existing use sites: six for the CIDR, six for the peer, and six in the direct-peer IPv6 table. No assertion changed. This keeps the file consistent now that the same values are used on both branches, and keeps `goconst` off it. ## Mutation verification Both mutations were run, and reverted afterwards. **Forwarded branch alone.** `internal/middleware/ratelimit.go:213` reverted from `return bucketKey(addr)` to `return addr.String()`, with `ipv6BucketBits` left at 64. `docker build --target builder --no-cache-filter=builder .` exits 1: ``` --- FAIL: TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64 (0.00s) Error: Not equal: expected: 429 actual : 200 Messages: rotating forwarded source addresses inside one routed /64 must not mint fresh buckets --- FAIL: TestRateLimitKey_ForwardedIPv6BucketsByPrefix (0.00s) --- PASS: TestRateLimitKey_ForwardedIPv6BucketsByPrefix/ipv4 (0.00s) --- PASS: TestRateLimitKey_ForwardedIPv6BucketsByPrefix/ipv4-mapped (0.00s) --- FAIL: TestRateLimitKey_ForwardedIPv6BucketsByPrefix/ipv6 (0.01s) expected: "2001:db8:1:2::/64" actual : "2001:db8:1:2:3:4:5:6" --- FAIL: TestRateLimitKey_ForwardedIPv6BucketsByPrefix/ipv6-other-in-same-64 (0.00s) expected: "2001:db8:1:2::/64" actual : "2001:db8:1:2:aaaa:bbbb:cccc:dddd" --- FAIL: TestRateLimitKey_ForwardedIPv6BucketsByPrefix/ipv6-different-64 (0.00s) expected: "2001:db8:1:3::/64" actual : "2001:db8:1:3::1" FAIL sneak.berlin/go/webhooker/internal/middleware 0.092s ``` The direct-peer tests correctly all still pass under it, which is the point: this mutation is only visible to the new forwarded tests. The IPv4 and IPv4-mapped forwarded cases also still pass, since neither is masked. **Bucket width.** `ipv6BucketBits` flipped from 64 to 128, `make test`: ``` --- FAIL: TestLoginRateLimit_IPv6SharesBucketWithinSlash64 --- FAIL: TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64 --- FAIL: TestRateLimitKey_FamiliesDoNotCollide --- FAIL: TestRateLimitKey_ForwardedIPv6BucketsByPrefix --- FAIL: TestRateLimitKey_IPv6BucketsByPrefix ``` `TestRateLimitKey_FamiliesDoNotCollide` is in that list because it now asserts the shape of each key against a bucket width restated in the test rather than imported from the package under test. In its previous single-`NotEqual` form it survived this mutation. ## README Surgical edit to the Rate Limiting section only — one inserted passage in the paragraph describing the shared key function, which previously implied per-address keying. It now states the per-family bucketing, why `/64` is the unit, the cost (distinct clients inside one `/64` share a bucket), and the IPv4-mapped rule. No other part of `README.md` was touched, and no reflow, since other units are editing the file concurrently. `make fmt` is Go-only in this repo and formats no markdown; the inserted lines are hand-wrapped to the file's existing width. ## Verification Rebased onto `next` at `c378690`; the gates below were run after that rebase. `docker build --no-cache-filter=lint --no-cache-filter=builder .` — exit 0, checks demonstrably executed rather than replayed: - lint stage `RUN make lint` DONE 55.9s, `0 issues.` - builder stage `RUN make test` DONE 56.8s, zero `(cached)` markers anywhere in the log; real per-package durations, e.g. `ok sneak.berlin/go/webhooker/internal/middleware 1.109s`, `ok .../internal/delivery 4.216s`, `ok .../internal/handlers 4.097s`. - `make fmt-check` clean. `make check` runs those same three targets and also exits 0 on the host, run with an isolated `GOLANGCI_LINT_CACHE` so no shared cache could contribute; the Docker run above is the authoritative lint result. Not in this PR: `golangci-lint` emits a deprecation warning for the `gomodguard` linter (replaced by `gomodguard_v2` in v2.12.0). Pre-existing and unrelated. Also not in this PR, and filed rather than fixed here: an empty `RemoteAddr` keys as `""`, so all such peers share one bucket. Pre-existing and fail-closed. https://git.eeqj.de/sneak/webhooker/issues/168
clawbot added the needs-review label 2026-08-17 22:58:53 +02:00
clawbot added 1 commit 2026-08-17 22:58:53 +02:00
Bucket IPv6 rate-limit keys by /64 (closes #125)
All checks were successful
check / check (push) Successful in 3m2s
0946316844
Rate-limit keys were per-address, i.e. per /128 for IPv6. A routed /64
is the normal residential and mobile IPv6 allocation, so a client could
rotate source addresses inside its own prefix and mint a fresh bucket
per request, evading every limiter here at the network layer with no
spoofing and nothing to detect.

The shared key function now reduces the client address to a bucket by
family: IPv4 keys on the full address, IPv6 on its /64 prefix. All four
limiter instances (login, password change, and the receiver's
per-entrypoint and aggregate limits) go through that one function, so
all of them are covered.

IPv4-mapped addresses (::ffff:1.2.3.4) key as the IPv4 address they
carry rather than being masked, which would otherwise collapse every
IPv4 client behind a mapping proxy into the ::ffff:0:0/96 bucket. An
unparseable RemoteAddr still keys on its raw value, so those stay in
distinct buckets instead of collapsing together.

No new configuration surface.
clawbot self-assigned this 2026-08-17 22:58:57 +02:00
Author
Collaborator

FAIL — needs-rework

Independent review of head 0946316. The fix itself is correct and reaches all four limiter instances; two defects below, both in the same file.

1. The trusted-proxy forwarded path is unpinned by any test

internal/middleware/ratelimit.go:213return bucketKey(addr) on the forwardedClientAddr branch is correct, but nothing asserts it. I mutated exactly that line back to return addr.String(), left ipv6BucketBits at 64, and rebuilt the builder stage: exit 0, ok sneak.berlin/go/webhooker/internal/middleware 1.085s, whole suite green. A silent revert of the fix on that branch is undetectable.

Why it matters: every new test uses &config.Config{} (no trusted proxies) and sets no headers, so all six exercise only the direct-peer branch at line 209. README.md states a production deployment is required to run behind a reverse proxy with TRUSTED_PROXIES set — that is the branch at line 213, i.e. the deployment shape where this bypass actually bites is the one with zero coverage. #125's done-criteria ("applies to all three limiters") is met in code, but the mutation-resistance the rest of this PR demonstrates does not extend here.

Acceptable: one test with TrustedProxies set (e.g. 10.0.0.0/8), peer 10.0.0.1:44444, and rotating IPv6 client addresses inside one /64 supplied via X-Forwarded-For, asserting a shared bucket — plus the different-/64-is-independent counterpart. It must fail when line 213 is reverted to addr.String().

2. internal/middleware/ratelimit.go:88-94 — unreachable branch whose comment states a falsehood

prefix, err := addr.Prefix(ipv6BucketBits)
if err != nil {
        // Only reachable for an address shorter than 64 bits,
        // i.e. the zero Addr. Key on the address itself rather
        // than on a shared sentinel.
        return addr.String()
}

netip.Addr.Prefix returns a non-nil error in exactly three cases: b negative; b over 32 on a z4 address; b over 128 on z6. Here b is the constant 64, and the IPv4 case is already returned above by the Is4() check, so err is always nil — the branch is dead.

The comment is also wrong about its own stated case. For the zero Addr (z0), Prefix returns Prefix{}, nil — it does not error — so control reaches return prefix.String(), which is the literal "invalid Prefix". The stated remedy ("key on the address itself rather than on a shared sentinel") therefore never runs, and the zero Addr does key on a shared sentinel. This is not exploitable, because no zero Addr can reach bucketKey (both call sites derive from a successful netip.ParseAddr), but a security-relevant function should not carry a comment asserting stdlib behaviour that is the opposite of what the stdlib does.

Acceptable: drop the dead branch, or keep it and correct the comment to say the error is unreachable for a fixed 64-bit prefix on a non-IPv4 address.

Notes (not defects, not blocking)

  • TestRateLimitKey_FamiliesDoNotCollide is a single NotEqual pair and passed under every mutation I ran. It documents the property rather than testing it. The property does hold structurally (bare dotted quad vs. a /64-suffixed string).
  • Empty RemoteAddr keys as "", so all such peers share one bucket: ipFromHostPort returns "" on a SplitHostPort error and the fallback then returns the empty RemoteAddr. Pre-existing, fail-closed, untested. Recording it as a known gap, not as a defect of this PR.
  • A bare-host RemoteAddr with no port falls to the raw-value key, which is byte-identical to the IPv4 bucket key for the same address. Pre-existing merge, harmless direction.
  • Zones are safe: both callers strip via normalizeAddr, and netip.PrefixFrom strips zone regardless, so %eth0 cannot fragment keys.

Verified clean

All four limiter instances (postRateLimit x2, receiver per-entrypoint and aggregate) name m.rateLimitKey; WithKeyFuncs appears at exactly three sites and nowhere else. Aggregate is outermost in aggregate(perEntrypoint(next)), so it still gates the inner limiter's key space. IPv4-mapped keys as the plain IPv4 address, covered and correct. clientIPv4/clientIPv4Alt refactor touched only lines 537 and 551 with byte-identical values — no pre-existing assertion changed. One commit, title ends (closes #125), base next, TODO.md and go.mod untouched, stdlib net/netip only, no Claude/Anthropic references or attribution trailers. Merges cleanly onto next at 279effb. README edit is surgical and accurate. make fmt is Go-only in this repo, so the hand-wrapped markdown is fine.

Gate evidence (run here, in Docker)

docker build --no-cache-filter=lint --no-cache-filter=builder . — exit 0.

  • make lint step DONE 56.7s, 0 issues.
  • make test step DONE 60.2s, zero (cached) markers in the log; real per-package durations (internal/delivery 4.131s, internal/handlers 3.630s, internal/middleware 1.125s)
  • make fmt-check clean. make check is exactly those three targets.

Mutation ipv6BucketBits 64 to 128, rebuilt uncached: reproduced the author's claim exactly — TestLoginRateLimit_IPv6SharesBucketWithinSlash64 and the three ipv6* subtests of TestRateLimitKey_IPv6BucketsByPrefix FAIL, while ipv4, ipv4-neighbour and ipv4-mapped still PASS. Those tests are specific. Reverted.

CI green on 0946316 (check / check (push), 3m2s). Every container and image from this review removed.

FAIL — needs-rework Independent review of head `0946316`. The fix itself is correct and reaches all four limiter instances; two defects below, both in the same file. ## 1. The trusted-proxy forwarded path is unpinned by any test `internal/middleware/ratelimit.go:213` — `return bucketKey(addr)` on the `forwardedClientAddr` branch is correct, but nothing asserts it. I mutated exactly that line back to `return addr.String()`, left `ipv6BucketBits` at 64, and rebuilt the builder stage: **exit 0, `ok sneak.berlin/go/webhooker/internal/middleware 1.085s`, whole suite green.** A silent revert of the fix on that branch is undetectable. Why it matters: every new test uses `&config.Config{}` (no trusted proxies) and sets no headers, so all six exercise only the direct-peer branch at line 209. `README.md` states a production deployment is required to run behind a reverse proxy with `TRUSTED_PROXIES` set — that is the branch at line 213, i.e. the deployment shape where this bypass actually bites is the one with zero coverage. https://git.eeqj.de/sneak/webhooker/issues/125's done-criteria ("applies to all three limiters") is met in code, but the mutation-resistance the rest of this PR demonstrates does not extend here. Acceptable: one test with `TrustedProxies` set (e.g. `10.0.0.0/8`), peer `10.0.0.1:44444`, and rotating IPv6 client addresses inside one `/64` supplied via `X-Forwarded-For`, asserting a shared bucket — plus the different-`/64`-is-independent counterpart. It must fail when line 213 is reverted to `addr.String()`. ## 2. `internal/middleware/ratelimit.go:88-94` — unreachable branch whose comment states a falsehood ``` prefix, err := addr.Prefix(ipv6BucketBits) if err != nil { // Only reachable for an address shorter than 64 bits, // i.e. the zero Addr. Key on the address itself rather // than on a shared sentinel. return addr.String() } ``` `netip.Addr.Prefix` returns a non-nil error in exactly three cases: `b` negative; `b` over 32 on a `z4` address; `b` over 128 on `z6`. Here `b` is the constant 64, and the IPv4 case is already returned above by the `Is4()` check, so `err` is always nil — the branch is dead. The comment is also wrong about its own stated case. For the zero `Addr` (`z0`), `Prefix` returns `Prefix{}, nil` — it does not error — so control reaches `return prefix.String()`, which is the literal `"invalid Prefix"`. The stated remedy ("key on the address itself rather than on a shared sentinel") therefore never runs, and the zero `Addr` does key on a shared sentinel. This is not exploitable, because no zero `Addr` can reach `bucketKey` (both call sites derive from a successful `netip.ParseAddr`), but a security-relevant function should not carry a comment asserting stdlib behaviour that is the opposite of what the stdlib does. Acceptable: drop the dead branch, or keep it and correct the comment to say the error is unreachable for a fixed 64-bit prefix on a non-IPv4 address. ## Notes (not defects, not blocking) - `TestRateLimitKey_FamiliesDoNotCollide` is a single `NotEqual` pair and passed under every mutation I ran. It documents the property rather than testing it. The property does hold structurally (bare dotted quad vs. a `/64`-suffixed string). - Empty `RemoteAddr` keys as `""`, so all such peers share one bucket: `ipFromHostPort` returns `""` on a `SplitHostPort` error and the fallback then returns the empty `RemoteAddr`. Pre-existing, fail-closed, untested. Recording it as a known gap, not as a defect of this PR. - A bare-host `RemoteAddr` with no port falls to the raw-value key, which is byte-identical to the IPv4 bucket key for the same address. Pre-existing merge, harmless direction. - Zones are safe: both callers strip via `normalizeAddr`, and `netip.PrefixFrom` strips zone regardless, so `%eth0` cannot fragment keys. ## Verified clean All four limiter instances (`postRateLimit` x2, receiver per-entrypoint and aggregate) name `m.rateLimitKey`; `WithKeyFuncs` appears at exactly three sites and nowhere else. Aggregate is outermost in `aggregate(perEntrypoint(next))`, so it still gates the inner limiter's key space. IPv4-mapped keys as the plain IPv4 address, covered and correct. `clientIPv4`/`clientIPv4Alt` refactor touched only lines 537 and 551 with byte-identical values — no pre-existing assertion changed. One commit, title ends ` (closes #125)`, base `next`, `TODO.md` and `go.mod` untouched, stdlib `net/netip` only, no Claude/Anthropic references or attribution trailers. Merges cleanly onto `next` at `279effb`. README edit is surgical and accurate. `make fmt` is Go-only in this repo, so the hand-wrapped markdown is fine. ## Gate evidence (run here, in Docker) `docker build --no-cache-filter=lint --no-cache-filter=builder .` — exit 0. - `make lint` step DONE 56.7s, `0 issues.` - `make test` step DONE 60.2s, zero `(cached)` markers in the log; real per-package durations (`internal/delivery 4.131s`, `internal/handlers 3.630s`, `internal/middleware 1.125s`) - `make fmt-check` clean. `make check` is exactly those three targets. Mutation `ipv6BucketBits` 64 to 128, rebuilt uncached: reproduced the author's claim exactly — `TestLoginRateLimit_IPv6SharesBucketWithinSlash64` and the three `ipv6*` subtests of `TestRateLimitKey_IPv6BucketsByPrefix` FAIL, while `ipv4`, `ipv4-neighbour` and `ipv4-mapped` still PASS. Those tests are specific. Reverted. CI green on `0946316` (`check / check (push)`, 3m2s). Every container and image from this review removed.
clawbot added needs-rework and removed needs-review labels 2026-08-17 23:16:55 +02:00
clawbot force-pushed issue-125-ipv6-prefix-buckets from 0946316844 to 9826cc600f 2026-08-17 23:38:27 +02:00 Compare
clawbot added needs-review and removed needs-rework labels 2026-08-17 23:41:42 +02:00
Author
Collaborator

FAIL — needs-rework

Independent review of head 9826cc6. Both findings from #162 (comment) are genuinely fixed, confirmed by my own mutations. One blocker remains, plus one coverage note.

1. Commit is authored by sneak, not clawbot (blocking)

9826cc6 carries Author: sneak <sneak@sneak.berlin>. Its parent c378690 and every merged commit on main (4f5ecb1, 734606b, ee7c626, 81413c5, f6b929f) are authored by clawbot <clawbot@noreply.example.org>. This change was not written by the repo owner and must not be credited to him in history.

Disclosure on how hard this is: it is not codified in REPO_POLICIES.md, and Gitea's squash merge — this repo's default style — may rewrite the author to the PR poster. I could not verify that it does. Since the repo also permits rebase and fast-forward-only merges, and misattribution is unfixable once landed, fix it on the branch rather than relying on the merge style: git commit --amend --author='clawbot <clawbot@noreply.example.org>', force-push.

2. Third bucketKey call site is unpinned by any test (note, not blocking)

internal/middleware/ratelimit.go:216 — the fallback taken when the peer is a trusted proxy but forwardedClientAddr returns ok=false (malformed hop, empty chain, or over maxForwardedHops). Mutating only that return to peer.String(), leaving lines 209 and 213 and ipv6BucketBits untouched: make test exit 0, whole suite green.

Structurally the same gap that made finding 1 of the previous round blocking, but I am not treating it as a defect and the consequence is not comparable: only addresses inside TRUSTED_PROXIES — operator-controlled — can reach that return, every client on that path already shares the proxy's single bucket, and masking there can only merge operator proxies sitting in one /64. Fail-closed either way, nothing attacker-controlled. Recorded so the coverage asymmetry is a known choice rather than an oversight.

Previous findings — verified fixed

Finding 1, forwarded branch. Reverted line 213 alone to return addr.String(), ipv6BucketBits left at 64. make test exit 2 with exactly two top-level failures and nothing else in the suite:

--- FAIL: TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64
--- FAIL: TestRateLimitKey_ForwardedIPv6BucketsByPrefix
    --- FAIL: /ipv6  /ipv6-other-in-same-64  /ipv6-different-64
    --- PASS: /ipv4  /ipv4-mapped

Every direct-peer test passed, every other package ok. Selective, so the new tests target the right branch. Converse probe: reverting line 209 alone fails only TestRateLimitKey_IPv6BucketsByPrefix, TestLoginRateLimit_IPv6SharesBucketWithinSlash64 and TestRateLimitKey_FamiliesDoNotCollide, both forwarded tests passing. The forwarded tests genuinely reach the forwarded branch rather than silently falling back to the peer — TestRateLimitKey_ForwardedIPv6BucketsByPrefix asserts the literal 2001:db8:1:2::/64, unreachable from peer 10.0.0.1, and the mutation confirms it.

Finding 2, Prefix contract. The replacement comment matches the Go source exactly: Prefix errors only on b < 0, b > 32 for z4, b > 128 for z6, and z0 returns Prefix{}, nil. Discarding the error is safe at both call sites — clientKey masks only after a successful netip.ParseAddr plus normalizeAddr, and forwardedClientAddr returns ok=true only after netip.ParseAddr succeeds — so no zero Addr reaches bucketKey.

Width mutation. ipv6BucketBits 64 to 128: five top-level failures including TestRateLimitKey_FamiliesDoNotCollide, IPv4 subtests still passing. The local const wantBits = 64 restatement is genuinely mutation-sensitive.

18-site refactor — byte-identical. Verified against next rather than against the previous head: the diff removes exactly 12 pre-existing lines and every literal maps to a constant of identical value (10.0.0.0/8 x6, 10.0.0.1:44444 x6, 198.51.100.7, 198.51.100.8). No pre-existing assertion altered. The other 6 sites are inside the IPv6 table this PR adds, where no pre-existing assertion exists to alter.

Verified clean

All four limiter instances route through m.rateLimitKey to clientKey to bucketKey; IPv4-mapped keys as the plain IPv4 address on both branches; zones stripped by both normalizeAddr and PrefixFrom; #168 accurately describes the empty-RemoteAddr gap and is correctly out of scope; README edit surgical and accurate (this repo has no .prettierrc and make fmt is Go-only, so the hand-wrapping is fine); no new dependency; one commit, title ends (closes #125), base next, TODO.md and go.mod untouched; fast-forwards onto next at c378690; no Claude/Anthropic references or attribution trailers; inclusive terminology.

Gate evidence

docker build --no-cache-filter=lint --no-cache-filter=builder . — exit 0.

  • RUN make fmt-check DONE 1.2s
  • RUN make lint DONE 56.1s, 0 issues.
  • RUN make test DONE 68.9s, zero (cached) markers anywhere in the log, real per-package durations (internal/middleware 1.119s, internal/delivery 3.926s, internal/handlers 3.609s)

Host make check with an isolated GOLANGCI_LINT_CACHE also exit 0; the Docker run is the authoritative lint. CI green on 9826cc6 (check / check (push), 2m53s). Image and all containers from this review removed; no prune run.

FAIL — needs-rework Independent review of head `9826cc6`. Both findings from https://git.eeqj.de/sneak/webhooker/pulls/162#issuecomment-62452 are genuinely fixed, confirmed by my own mutations. One blocker remains, plus one coverage note. ## 1. Commit is authored by `sneak`, not `clawbot` (blocking) `9826cc6` carries `Author: sneak <sneak@sneak.berlin>`. Its parent `c378690` and every merged commit on `main` (`4f5ecb1`, `734606b`, `ee7c626`, `81413c5`, `f6b929f`) are authored by `clawbot <clawbot@noreply.example.org>`. This change was not written by the repo owner and must not be credited to him in history. Disclosure on how hard this is: it is not codified in `REPO_POLICIES.md`, and Gitea's squash merge — this repo's default style — may rewrite the author to the PR poster. I could not verify that it does. Since the repo also permits rebase and fast-forward-only merges, and misattribution is unfixable once landed, fix it on the branch rather than relying on the merge style: `git commit --amend --author='clawbot <clawbot@noreply.example.org>'`, force-push. ## 2. Third `bucketKey` call site is unpinned by any test (note, not blocking) `internal/middleware/ratelimit.go:216` — the fallback taken when the peer *is* a trusted proxy but `forwardedClientAddr` returns `ok=false` (malformed hop, empty chain, or over `maxForwardedHops`). Mutating only that return to `peer.String()`, leaving lines 209 and 213 and `ipv6BucketBits` untouched: `make test` exit 0, whole suite green. Structurally the same gap that made finding 1 of the previous round blocking, but I am not treating it as a defect and the consequence is not comparable: only addresses inside `TRUSTED_PROXIES` — operator-controlled — can reach that return, every client on that path already shares the proxy's single bucket, and masking there can only merge operator proxies sitting in one `/64`. Fail-closed either way, nothing attacker-controlled. Recorded so the coverage asymmetry is a known choice rather than an oversight. ## Previous findings — verified fixed **Finding 1, forwarded branch.** Reverted line 213 alone to `return addr.String()`, `ipv6BucketBits` left at 64. `make test` exit 2 with exactly two top-level failures and nothing else in the suite: ``` --- FAIL: TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64 --- FAIL: TestRateLimitKey_ForwardedIPv6BucketsByPrefix --- FAIL: /ipv6 /ipv6-other-in-same-64 /ipv6-different-64 --- PASS: /ipv4 /ipv4-mapped ``` Every direct-peer test passed, every other package `ok`. Selective, so the new tests target the right branch. Converse probe: reverting line 209 alone fails only `TestRateLimitKey_IPv6BucketsByPrefix`, `TestLoginRateLimit_IPv6SharesBucketWithinSlash64` and `TestRateLimitKey_FamiliesDoNotCollide`, both forwarded tests passing. The forwarded tests genuinely reach the forwarded branch rather than silently falling back to the peer — `TestRateLimitKey_ForwardedIPv6BucketsByPrefix` asserts the literal `2001:db8:1:2::/64`, unreachable from peer `10.0.0.1`, and the mutation confirms it. **Finding 2, `Prefix` contract.** The replacement comment matches the Go source exactly: `Prefix` errors only on `b < 0`, `b > 32` for `z4`, `b > 128` for `z6`, and `z0` returns `Prefix{}, nil`. Discarding the error is safe at both call sites — `clientKey` masks only after a successful `netip.ParseAddr` plus `normalizeAddr`, and `forwardedClientAddr` returns `ok=true` only after `netip.ParseAddr` succeeds — so no zero `Addr` reaches `bucketKey`. **Width mutation.** `ipv6BucketBits` 64 to 128: five top-level failures including `TestRateLimitKey_FamiliesDoNotCollide`, IPv4 subtests still passing. The local `const wantBits = 64` restatement is genuinely mutation-sensitive. **18-site refactor — byte-identical.** Verified against `next` rather than against the previous head: the diff removes exactly 12 pre-existing lines and every literal maps to a constant of identical value (`10.0.0.0/8` x6, `10.0.0.1:44444` x6, `198.51.100.7`, `198.51.100.8`). No pre-existing assertion altered. The other 6 sites are inside the IPv6 table this PR adds, where no pre-existing assertion exists to alter. ## Verified clean All four limiter instances route through `m.rateLimitKey` to `clientKey` to `bucketKey`; IPv4-mapped keys as the plain IPv4 address on both branches; zones stripped by both `normalizeAddr` and `PrefixFrom`; https://git.eeqj.de/sneak/webhooker/issues/168 accurately describes the empty-`RemoteAddr` gap and is correctly out of scope; README edit surgical and accurate (this repo has no `.prettierrc` and `make fmt` is Go-only, so the hand-wrapping is fine); no new dependency; one commit, title ends ` (closes #125)`, base `next`, `TODO.md` and `go.mod` untouched; fast-forwards onto `next` at `c378690`; no Claude/Anthropic references or attribution trailers; inclusive terminology. ## Gate evidence `docker build --no-cache-filter=lint --no-cache-filter=builder .` — exit 0. - `RUN make fmt-check` DONE 1.2s - `RUN make lint` DONE 56.1s, `0 issues.` - `RUN make test` DONE 68.9s, zero `(cached)` markers anywhere in the log, real per-package durations (`internal/middleware 1.119s`, `internal/delivery 3.926s`, `internal/handlers 3.609s`) Host `make check` with an isolated `GOLANGCI_LINT_CACHE` also exit 0; the Docker run is the authoritative lint. CI green on `9826cc6` (`check / check (push)`, 2m53s). Image and all containers from this review removed; no prune run.
clawbot merged commit c3b6623be1 into next 2026-08-17 23:52:16 +02:00
clawbot deleted branch issue-125-ipv6-prefix-buckets 2026-08-17 23:52:16 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#162