feat(service): restrict a service by client address with --allow-ip - #47
Merged
Conversation
## Summary Adds `kamal-proxy deploy --allow-ip <cidr>` (plus `--trusted-proxy`) and `kamal-proxy run --metrics-allow-ip <cidr>`. Requests from outside the list get a 403. **The filter matches the connecting peer, never a header.** Every IP-shaped value on a request is written by the client, so honouring one by default would make the list decorative — anyone could send `X-Forwarded-For: 10.0.0.1`. Note that the anchor this issue and the ROADMAP both named, `logging_middleware.go:70`, is exactly the trap: that function's `remote_addr` field is raw `X-Forwarded-For` with no trust check at all. Both are corrected here. Headers are consulted only when the peer is itself inside `--trusted-proxy`, and then the chain is walked from the nearest hop backwards past our own proxies. An unresolvable chain denies rather than falling back to the peer, which would be a bypass whenever the allow list contains the proxy's own range. Three bypasses were closed that a straightforward implementation would ship: - `Header.Get` returns only the FIRST of repeated header lines, and HAProxy's `option forwardfor` appends a new line rather than folding. A client that sends its own header therefore owns the whole walk. Reads use `Values`. - `netip.Prefix.Contains` reports false for `::ffff:203.0.113.5` against `203.0.113.0/24`, so connecting over IPv6 would bypass a v4 list. Addresses are unmapped before matching. - A zoned address such as `fe80::1%eth0` parses fine but matches nothing, so storing one would silently do nothing. Rejected at parse time. Not a `createMiddleware` middleware: that chain includes the certificate manager's handler, so filtering there would block ACME HTTP-01 and break renewal weeks later. The check is the first thing `serviceRequestWithTarget` does, before the HTTPS redirect — a 403 solicits nothing, so there is no reason to redirect a peer we are about to refuse. Allow-only; no `--deny-ip`. Ordered allow/deny semantics depend on argument order, which is a footgun in config a gem regenerates, and a static deny list is the wrong tool for the abuse-blocking people reach for it with. Adding it later is additive. Also extracts `validateInterceptErrorStatuses`/`validateDynamicDomains` into `service_options_validation.go`: `service.go` was at 790 of the hard 800-line ceiling, and the rule says to split before adding. ## Test Coverage - TestIPAllowList_IgnoresForgedFirstForwardedHeaderLine: the repeated-header bypass - TestIPAllowList_MatchesNormalizedAddresses: v4-mapped v6, zones, v6-vs-v4 - TestIPAllowList_IgnoresForwardedHeadersWithoutTrustedProxy: no header buys access - TestIPAllowList_ResolvesThroughTrustedProxy / _DeniesUnresolvableForwardedChain - TestIPAllowList_RejectsBeforeRedirecting / _RejectsBeforeChallengingBasicAuth - TestIPAllowList_ExemptsHealthCheckRequests / _RejectsRootHealthCheckPath - TestIPAllowList_StateWrittenBeforeTheOptionStaysUnrestricted, _EmptyStoredListStaysUnrestricted, _UnreadableStoredEntryFailsClosed - TestMetricsAllowList, incl. an invalid entry failing the boot with metrics off Three mutations were applied and confirmed to fail their tests: Values reverted to Get, Unmap dropped, and unresolvable-chain falling back to the peer. ## Verification - [x] gofmt -l internal/ cmd/ clean - [x] go vet ./... clean - [x] make test passes - [x] go test -race ./internal/server/ ./internal/cmd/ — 766 tests, clean - [x] BenchmarkIPAllowList_Permits: 51 ns/op, 0 allocs/op Closes #9
staticcheck S1011. Same behavior, and CI is the only place golangci-lint runs -- it is not installed locally.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
kamal-proxy deploy service1 --target web-1:3000 --allow-ip 10.0.0.0/8,203.0.113.7Requests from outside the list get
403. Metrics get their own list:kamal-proxy run --metrics-port 9090 --metrics-allow-ip 10.0.0.0/8. Closes #9.The filter matches the connecting peer, never a header
Every IP-shaped value on a request is written by the client, so honouring one by default makes the list decorative — anyone sends
X-Forwarded-For: 10.0.0.1and walks in.The anchor this issue and
ROADMAP.mdboth named is the trap.logging_middleware.go:70computes two values:client_addrfromr.RemoteAddr(trustworthy) andremote_addrfromr.Header.Get("X-Forwarded-For")— unconditionally, with no trust check (logging_middleware.go:77). Reusing the one calledremote_addrwould have shipped a bypass. Both the issue's framing and the ROADMAP row are corrected in this PR.Headers are consulted only when the peer is itself inside
--trusted-proxy, and then the chain is walked from the nearest hop backwards past our own proxies. An unresolvable chain denies rather than falling back to the peer — that fallback is a bypass whenever the allow list contains the proxy's own range, which is a very plausible config.Three bypasses closed that a straightforward implementation ships
Each verified by hand against go1.26.5 before writing code:
Header.Getreturns only the first line. HAProxy'soption forwardforappends a new line rather than folding, so a client that sends its own header owns the entire right-to-left walk.h.Add("X-Forwarded-For","10.0.0.1"); h.Add(...,"203.0.113.9")→Get() == "10.0.0.1". Reads useValues.netip.Prefix("203.0.113.0/24").Contains(::ffff:203.0.113.5)is false;.Unmap()makes it true.fe80::1%eth0parses fine but matches nothing, so storing one silently does nothing.ParseAddrkeeps the zone;fe80::/10.Contains()of it is false. Rejected at parse time.Placement
Not a
createMiddlewaremiddleware. That chain includes the certificate manager's handler, so filtering there blocks ACME HTTP-01 and breaks renewal 60–90 days later — a failure that never shows up at deploy. The check is the first thingserviceRequestWithTargetdoes, before the HTTPS redirect: a 403 solicits nothing, so there is no reason to redirect a peer we are about to refuse. This is the deliberate inverse of the basic-auth ordering from #46, and both directions are pinned by tests.Allow-only, no
--deny-ipThe issue title says "allow/deny" but its body describes two allow lists. Ordered allow/deny semantics depend on argument order, which is a footgun in config a gem regenerates; Traefik shipped
IPAllowListwith no deny counterpart; and a static deny list is the wrong tool for the abuse-blocking people reach for it with. Adding it later is additive.Also in this PR
validateInterceptErrorStatusesandvalidateDynamicDomainsmove toservice_options_validation.go.service.gowas at 790 of the hard 800-line ceiling in.claude/rules/coding-style.md, which says to split before adding rather than grow. It now sits at 756. Both are fork-only validators, so moving them also keeps upstream merge surface localized.Test plan
make test,go vet ./...,gofmt -l internal/ cmd/cleango test -race ./internal/server/ ./internal/cmd/— 766 tests, cleanmake build+--helpshows all three flagsBenchmarkIPAllowList_Permits— 51 ns/op, 0 B/op, 0 allocs/op (8-entry list, resolve + match). No before/after claimed: this is a new path that did not exist ondash, and it is skipped entirely when no list is set.Three mutations applied and confirmed to fail their tests, so these prove behavior rather than plumbing:
Values()reverted toGet()TestIPAllowList_IgnoresForgedFirstForwardedHeaderLineUnmap()droppedTestIPAllowList_MatchesNormalizedAddresses(2 cases) +TestMetricsAllowListTestIPAllowList_DeniesUnresolvableForwardedChain(5 cases)Known gaps, documented not fixed
r.RemoteAddris the bridge gateway. Rootless Docker or a userland-proxy driver collapses every source to e.g.172.17.0.1, and the list then allows everyone or no one. Unspoofable is not the same as meaningful. README tells operators to checkclient_addrin the access log.--client-ip-headernames a header the edge must overwrite or strip. The combination now requires--trusted-proxy(hard error otherwise), but a header the edge merely passes through —True-Client-IPon non-Enterprise Cloudflare is the canonical trap — can still be set by anyone. Documented./when a list is set. Same shape as basic auth.s.allowedIPsis read without a lock, exactly ass.basicAuthands.optionsalready are, so a redeploy that adds a list has a brief window where in-flight requests see the old value. Inherited, not introduced — I am not claiming-racecovers it, because no test drives a redeploy concurrently with traffic. Worth its own issue alongside the same problem for basic auth.ClientIPMiddlewareand theremote_addrlog field remain unconditionally header-trusting. Changing them is a behavior change in an upstream file and belongs in its own PR — but it means that on a spoofed request the access log'sremote_addrand the filter's decision disagree.Deviations & judgment calls
Design came from a multi-agent pass and three adversarial reviews. The reviews found a blocker and six other real problems, all folded in before any code was written.
Header.Values()joined, notHeader.Get()— the blocker, found independently by two of the three reviewers, and reproduced by hand before I trusted it. Without this the trusted-proxy walk runs entirely inside attacker-authored data.--client-ip-headerdirectly, not theX-Forwarded-ForthatClientIPMiddlewarerewrote. That middleware doesr.Header.Set("X-Forwarded-For", ...)from an unvalidated header and runs outermost, so by the time the check runs the genuine chain is gone, replaced by one attacker-settable value. Reading the original named header keeps the middleware's outbound rewrite from deciding access.--allow-ip+--client-ip-headerwithout--trusted-proxyis a hard deploy error — otherwise the combination looks like it works while silently matching the edge.token_bucket.go) carrying the peer, the resolved address, and a reason. The design declined one; the review pointed out that its own worst failure mode — a trusted edge that stops sending the header, denying everything — is then completely invisible, since the access log shows the edge asclient_addrand a chain that looks fine asremote_addr.MetricsPort == 0early return, so a typo fails the boot even with metrics off. The design put it after, where it could never run, while claiming it "fails the boot".--trusted-proxydocuments that every hop must be listed, with the failure mode spelled out in the README.[]into a total outage. Absent fails open (the operator never asked for a filter); an unreadable stored entry fails closed for that one service (they did ask, and we cannot honour it) without aborting the whole state decode.--bindon a private interface is the more idiomatic answer; it is, but it is a larger change (Config.Bindcurrently governs all three listeners), and the issue explicitly asks for metrics by CIDR. Happy to file the--bindfollow-up.