Skip to content

feat(proxy): expose rate limiting, IP allow lists and client-IP identification - #88

Merged
mhenrixon merged 1 commit into
dashfrom
issue-77-access-control
Jul 30, 2026
Merged

mhenrixon merged 1 commit into
dashfrom
issue-77-access-control

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

The R3 access-control batch, reachable from deploy.yml. All three blocks ship together because rate limiting and IP allow lists are only as correct as the client address they key on, and client_ip is what establishes it.

proxy:
  client_ip:
    header: CF-Connecting-IP
    trusted_proxies: [ 173.245.48.0/20 ]
  rate_limit:
    requests: 100
    burst: 20
    exempt: [ 10.0.0.0/8 ]
  allow_ips: [ 10.0.0.0/8, 192.168.0.0/16 ]
  • lib/kamal/configuration/proxy.rb — an access_control_options group merged into deploy_options and stripped when load balancing.
  • lib/kamal/configuration/loadbalancer.rb — re-adds it at the edge.
  • lib/kamal/configuration/validator/proxy.rb — ten config-time rejections, plus a validate_key_override! for rate_limit.
  • lib/kamal/configuration.rb — ensure_rate_limit_can_identify_clients, the AC1 warning.
  • test/proxy_flag_coverage_test.rb — issue-77 waiver deleted; all six flags proven emitted.

Closes #77

The forwarded-chain semantics, from the source rather than guessed

The issue said to read eb4273f before designing trusted_proxies. That commit is a two-line staticcheck fix (S1011) whose own message says "Same behavior" — it decides nothing. The semantics live in internal/server/ip_allow_list.go, in forwardedResolver#clientAddr/#forwardedAddr:

  • With no trusted_proxies, the client is always the connecting address. Nothing a client sends can influence it — that is what makes the allow list meaningful.
  • With trusted_proxies set and the connecting address being one of them, the chain is walked from the nearest hop backwards past every declared proxy; the first address none of them wrote is the client.
  • An unresolvable chain returns the zero address, which denies. It does not fall back to the peer — that would be a bypass on the very plausible config where the allow list contains the proxy's own range.

Hence the docs' emphasis on listing every hop, not just the one that connects to kamal-proxy.

Config-time rejections

Eight kamal-proxy enforces only once the deploy reaches a host:

Config Error
trusted_proxies alone trusted_proxies has no effect without allow_ips or rate_limit
client_ip.header without trusted_proxies header requires trusted_proxies, or the header would be ignored while appearing to be honored
trusted_proxies: [ 0.0.0.0/0 ] is a default route - trusting every address means trusting every client to speak for someone else
rate_limit.burst / .exempt without requests … has no effect without requests
negative requests / burst … cannot be negative
malformed CIDR anywhere … is not a valid address or CIDR range
healthcheck.path: / with either feature on path cannot be '/' … served without an address check or a rate limit

Plus two that are Ruby-specific — IPAddr is looser than the proxy's netip:

Config Why it must be caught here
fe80::1%eth0 IPAddr silently drops the zone; the proxy rejects it because a zoned address matches nothing
::ffff:10.0.0.0/104 IPAddr accepts it; it would never match a plain IPv4 range

Test plan

  • bundle exec rubocop --parallel — clean, 212 files
  • Unit suite — 1277 runs; 4 failures + 3 errors, all in builder_test/build_test, identical on pristine dash HEAD (host-arch dependent, pass in CI)
  • Generated command inspected by hand — all six flags in the expected repeated form
  • Deploy with allow_ips and confirm an outside address gets 403 while the health check stays reachable
  • Deploy behind Cloudflare with client_ip set and confirm the limiter buckets per visitor, not per CDN edge
  • Confirm rate_limit.requests: 100 returns 429 past the limit and exempt ranges are not throttled
  • Load-balanced deploy: confirm the six flags appear on the load balancer's deploy and not the per-app one

Automated coverage

Test Proves
allow_ips reach the proxy as repeated flags AC2 — repeated --allow-ip=, checked against internal/cmd/deploy.go's StringSliceVar
a malformed CIDR fails locally + the zone/IPv4-mapped tests AC3 — fails before the deploy reaches a host
rate limiting behind a proxy without trusted_proxies warns (+ two negative cases) AC1 — warns, does not refuse
no access control keys leave the deploy command unchanged AC4 — pure addition
access control moves to the load balancer when load balancing the flags land where the client is actually visible
a fractional rate is passed through as written 0.5 survives; the Float64 flag is not rounded
the seven dependency-rule tests the proxy's own rules, enforced early
every kamal-proxy deploy flag is exposed or waived all six really wired, waiver gone

Deviations & judgment calls

Deviations

  • None to the flag surface — all six flags, exactly the keys the issue proposed.
  • The issue's eb4273f pointer was a dead end (see above); the docs were written from ip_allow_list.go instead.

Judgment calls

  • Access control is stripped when load balancing and re-added at the load balancer, like host, tls, basic-auth and the Expose on-demand TLS, mTLS client CA and ACME cache path in deploy.yml #74 TLS options. Not in the issue, and sharper than the TLS case: the per-host proxy's peer is always the load balancer, so allow_ips there would 403 every request unless the operator happened to list the load balancer's own address, and one rate limiter would count the entire fleet as a single client. Leaving these on the per-app service is an outage, not a degraded feature.
  • The AC1 warning fires only on an explicit forward_headers: true. kamal-proxy also enables header forwarding by default when ssl: false, which would widen the warning to most non-TLS configs — and a warning that fires on nearly every deploy is one operators learn to skip. forward_headers: true is a deliberate "something is in front of me" statement, which is exactly the population that needs trusted_proxies.
  • Mirrored all of the proxy's rejections, not only the malformed-CIDR one AC3 asks for. Same shape, same cost, and each currently surfaces only after an SSH round-trip.
  • rate_limit.requests is validated by hand through validate_key_override! rather than from the docs example. The proxy's flag is a Float64, so 0.5 (one request every two seconds) is legal, but a YAML example can only demonstrate one numeric type and 100 there would reject it. The override still checks unknown keys and the other two types, so nothing is lost.
  • healthcheck.path is only rejected at the literal /. The gem omits --health-check-path when unset and the proxy defaults to /up, so an unset path is safe and does not need to be forced.

…ification

The whole R3 access-control batch was unreachable from deploy.yml.

    proxy:
      client_ip:
        header: CF-Connecting-IP
        trusted_proxies: [ 173.245.48.0/20 ]
      rate_limit:
        requests: 100
        burst: 20
        exempt: [ 10.0.0.0/8 ]
      allow_ips: [ 10.0.0.0/8, 192.168.0.0/16 ]

All three ship together because the first two are only as correct as the client
address they key on, and the third is what establishes it.

The issue pointed at eb4273f for the forwarded-chain semantics; that commit is a
two-line staticcheck fix whose own message says "Same behavior". The semantics
are in internal/server/ip_allow_list.go: with no trusted proxies the peer is
always the client; with trusted proxies AND a peer that is one of them, the chain
is walked from the nearest hop backwards past every declared proxy, and the first
address none of them wrote is the client. An unresolvable chain denies rather
than falling back to the peer. The docs now say that rather than guessing.

Access control is stripped from the per-app deploy when load balancing and
re-added at the load balancer, like host/tls/basic-auth. This is sharper than the
TLS case: the per-host proxy's peer is the load balancer, so allow_ips there
would 403 every request and one rate limiter would count the entire fleet as a
single client. Leaving them on is an outage, not a degraded feature.

Eight things kamal-proxy rejects only once the deploy reaches a host now fail at
config time, plus two Ruby-specific ones: IPAddr silently drops an IPv6 zone and
accepts IPv4-mapped forms, both of which match nothing at runtime while looking
configured.

And one warning rather than an error, per the issue: rate_limit with
forward_headers and no trusted_proxies is legal but almost certainly wrong, so it
says so instead of refusing to deploy.

rate_limit/requests is checked by hand via validate_key_override! -- the proxy's
flag is a Float64, so 0.5 is a legal rate, and the docs example can only
demonstrate one numeric type.

Closes #77
@mhenrixon
mhenrixon merged commit 4c75674 into dash Jul 30, 2026
9 checks 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.

Expose rate limiting, IP allow lists and client-IP identification in deploy.yml

1 participant