Skip to content

feat(deny-list): per-service deny rules with --deny-ip and --deny-user-agent - #93

Merged
mhenrixon merged 1 commit into
dashfrom
feature/deny-lists
Aug 9, 2026
Merged

feat(deny-list): per-service deny rules with --deny-ip and --deny-user-agent#93
mhenrixon merged 1 commit into
dashfrom
feature/deny-lists

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

  • New internal/server/deny_list.go: per-service denyList over the shared forwardedResolver--deny-ip (addresses/CIDRs, parsed with the same parseIPPrefixes as the allow list) and --deny-user-agent (RE2, compiled once at deploy, anchored to the full header value)
  • Runs as the very first gate in serviceRequestWithTarget: deny → allow → TLS redirect → rate limit → basic auth. A denied client never spends rate-limit budget and never sees an auth challenge; an address on both deny and allow lists is denied
  • IP checks before User-Agent checks (cheapest first). A missing User-Agent only matches an explicit ^$ pattern
  • 403 through the error-page machinery, no rule echo; health-check path and internal requests exempt, with the same "health-check path / is rejected" deploy guard as allow-ip/rate-limit
  • New kamal_proxy_denials_total{service,rule} counter (ip, user_agent)
  • Persisted in ServiceOptions (deny_ips, deny_user_agents, both omitempty) — old state files load unrestricted; unreadable stored rules fail closed
  • --trusted-proxy is now justified by deny-ip alone (previously required allow-ip or rate-limit); README section added

Closes #88

Test plan

  • deny_list_test.go — parsing/validation, IP matching (exact IPv6, IPv4-mapped normalization, zero-addr), anchored UA matching, empty-UA semantics, health-check guard
  • deny_list_service_test.go — 403 ordering vs every other gate, exemptions, forwarded-chain resolution, metrics by kind, state-file safety, JSON round-trip, fail-closed, redeploy removal
  • make test, go vet, golangci-lint, gofmt -l all clean; go test -race on the gate paths clean

Deviations & judgment calls

Deviations

  • No upstream remote exists in this clone; skipped the Phase-0 ff-sync of main and branched feature/deny-lists directly off origin/dash (current and clean).

Discoveries

  • --deny-user-agent must be StringArrayVar, not StringSliceVar like --allow-ip: RE2 patterns legally contain commas ({1,3}), and StringSlice would split them into broken rules. Same reasoning as the header-rule flags.
  • TestIdleController_PersistsOnlySleepAndWakeEdges failed once during the first full make test, passed 5× in isolation and on a full-package rerun — pre-existing timing flake, untouched by this change.

Judgment calls

  • Unreadable stored deny rules fail closed (deny every request to the service), matching the allow-list precedent in resolveIPAllowList: a block that silently lapsed would serve the very traffic the operator asked to refuse. Only corrupt state can hit this path, since deploys validate.
  • UA patterns are compiled as anchored full-matches (\A(?:p)\z) — the issue says "matched against the full User-Agent" and its BadBot/.* example only makes sense anchored.
  • Empty/missing User-Agent is denied only by a literal ^$ pattern — even .*, which technically matches the empty string, does not deny it ("absence is not a crime").
  • The denials counter has a third rule-kind label beyond the issue's two: unreadable, emitted only on the fail-closed path, so that state is visible in Prometheus rather than only in a log line.
  • validateAllowIPs' trusted-proxy justification check extended to accept deny-ip as a third legitimate reason (deny resolves clients through the same resolver).

…r-agent

## Summary
Static deny rules for operators fronting public tenant fleets, where an
allow list is definitionally impossible and a rate limit cannot express
"this network gets nothing" (#88). Rules are evaluated inline in
serviceRequestWithTarget as the very first gate -- deny, then allow, then
TLS redirect, rate limit, basic auth -- with the client resolved through
the shared forwardedResolver. Denials answer 403 through the error-page
machinery, are counted in kamal_proxy_denials_total by rule kind, and
persist in ServiceOptions like every other knob.

User-Agent rules are RE2, compiled once at deploy, anchored to the full
header value, and checked only after the IP rules pass. A missing
User-Agent only matches an explicit '^$' pattern.

## Test Coverage
- deny_list_test.go: parsing/validation, IP matching (exact IPv6, no /64
  collapse, IPv4-mapped normalization), anchored UA matching, empty-UA
  semantics, trusted-proxy justification, health-check-path guard
- deny_list_service_test.go: 403 ordering vs allow/redirect/rate-limit/
  basic-auth, health-check + internal exemptions, forwarded-chain
  resolution, denial metrics by kind, old-state-file safety, JSON
  round-trip, fail-closed on unreadable stored rules, redeploy removal

## Verification
- [x] gofmt -l internal/ cmd/ clean
- [x] make test passes
- [x] go vet ./... and make lint (golangci-lint) clean
- [x] go test -race on the request-gate paths clean
@mhenrixon mhenrixon self-assigned this Aug 9, 2026
@mhenrixon
mhenrixon merged commit 39eda9d into dash Aug 9, 2026
2 checks passed
@mhenrixon
mhenrixon deleted the feature/deny-lists branch August 9, 2026 18:03
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.

Per-service deny lists: --deny-ip and --deny-user-agent

1 participant