🔥 feat: Host auth middleware #4199
Conversation
|
Thanks for opening this pull request! 🎉 Please check out our contributing guidelines. If you need help or want to chat with us, join us on Discord https://gofiber.io/discord |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds a new hostauthorization middleware: implementation, configuration, documentation, README index entry, and comprehensive tests/benchmarks that enforce Host header allowlisting (exact, leading-dot wildcard, CIDR, dynamic validator) and rejection via a configurable error handler. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Client as Client
participant App as Fiber App
participant HA as HostAuthorization
participant Store as AllowedHostsStore
participant Func as AllowedHostsFunc
participant Err as ErrorHandler
Client->>App: HTTP request (Host header)
App->>HA: invoke middleware
HA->>HA: normalize Host (strip port/brackets/trailing dot, lowercase)
HA->>Store: check exact / wildcard / CIDR matches
alt matched
Store-->>HA: allowed
HA->>App: c.Next() -> downstream handler
else not matched
HA->>Func: if configured, call AllowedHostsFunc(host)
alt func returns true
Func-->>HA: allowed
HA->>App: c.Next() -> downstream handler
else
HA->>Err: call ErrorHandler(c, ErrForbiddenHost)
Err-->>Client: 403 Forbidden (or custom response)
end
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4199 +/- ##
==========================================
+ Coverage 91.15% 91.21% +0.06%
==========================================
Files 123 125 +2
Lines 12076 12137 +61
==========================================
+ Hits 11008 11071 +63
+ Misses 669 668 -1
+ Partials 399 398 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request introduces a new hostauthorization middleware for Fiber v3, designed to protect against DNS rebinding attacks by validating the Host header against an allowlist of exact domains, subdomain wildcards, and CIDR ranges. The implementation includes comprehensive documentation and tests. Feedback focuses on making the configuration initialization more idiomatic for the Fiber framework and optimizing the performance of subdomain matching by pre-storing wildcard suffixes with their leading dots to avoid allocations during request processing.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/middleware/hostauthorization.md`:
- Around line 63-79: The CIDR Ranges doc misleadingly describes source-IP
filtering; update the wording to clarify that hostauthorization checks the
request Host header against AllowedHosts (not the client's IP). Edit the
examples and comments around hostauthorization.New and hostauthorization.Config
AllowedHosts entries to say things like "allowed Host header IP range" or "Host
header value" instead of "internal network" or "respond to requests from known
IP ranges" so readers understand this matches Host header strings/CIDR-like IPs
in the Host header only.
In `@middleware/hostauthorization/config.go`:
- Around line 13-41: Add the missing Exclude hook to the public Config by
declaring a field Exclude func(c fiber.Ctx) bool (optional, default nil) next to
Next and ErrorHandler in the Config struct; update the middleware code path that
currently checks Next to also call Exclude (both should be evaluated before any
host validation/AllowedHosts/AllowedHostsFunc logic) and treat a true return
from Exclude as a signal to skip host validation and not invoke ErrorHandler.
Ensure the field name is exactly Exclude and that callers can provide it the
same way as Next/ErrorHandler.
In `@middleware/hostauthorization/hostauthorization.go`:
- Around line 24-45: Normalize each configured host string using normalizeHost()
before any checks and storage: call normalizeHost(h) (after trimming) and skip
if it becomes empty; for CIDR parsing still use the normalized value for
net.ParseCIDR and append the result to parsed.cidrNets; for wildcard entries
(case strings.HasPrefix(h, ".")) store the suffix without the leading dot from
the normalized value into parsed.wildcardSuffixes; for exact matches insert the
normalized host into parsed.exact. Ensure all references use the normalized host
variable so comparisons match request hosts.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: abe37dce-637a-44c8-80d2-c96bfa96bba2
📒 Files selected for processing (5)
.github/README.mddocs/middleware/hostauthorization.mdmiddleware/hostauthorization/config.gomiddleware/hostauthorization/hostauthorization.gomiddleware/hostauthorization/hostauthorization_test.go
|
Thanks for the contribution @mutantkeyboard , DNS rebinding protection is a valid concern and I appreciate the thorough work on docs and tests. Before we move forward, I want to raise a design question: should this be a standalone middleware or an extension of the existing
If we do keep it as a standalone middleware, a few things need addressing: Performance
API / Consistency
Documentation
Scope question
I'd like to hear your thoughts on the |
|
Hey @ReneWerner87, thank you for the review. Regarding I'm genuinely curious what did you have in mind there. My original inspiration for this was brannondorsey/host-validation, which is explicitly a standalone Performance
This one is actually a good catch - config entries with trailing dots won't match runtime-normalized hosts. Easy fix. CIDR docs This is actually a fair point. I can fix the wording to make clear it's matching the But regarding the overall architecture, I can integrate it into the existing WDYT? |
|
ok Go ahead with the leading-dot fix at init + normalizeHost on config entries, that alone should clean up the hot path nicely. And yes, please add an isolated benchmark for just the matching logic so we have real numbers for the middleware cost without pls also try the utils functions for the better performance |
|
@mutantkeyboard Test failures are now related to the new middleware. |
|
@gaby - I will fix that today |
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
middleware/hostauthorization/hostauthorization_test.go (1)
275-279:⚠️ Potential issue | 🟡 MinorTighten the empty-host error branch assertion.
On Lines 276-277, returning on any error can make this test pass for unrelated failures. Assert the expected Host-related failure before returning.
Suggested fix
resp, err := app.Test(req) if err != nil { + require.ErrorContains(t, err, "Host") return } require.Equal(t, fiber.StatusForbidden, resp.StatusCode)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@middleware/hostauthorization/hostauthorization_test.go` around lines 275 - 279, The test currently returns on any non-nil error from app.Test(req), which can mask unrelated failures; modify the app.Test(req) error branch to assert the error is the expected host-related failure (e.g., using require.ErrorContains(t, err, "host" or "empty host") or an equivalent check against the Host validation error) before returning, then keep the existing require.Equal(t, fiber.StatusForbidden, resp.StatusCode) assertion for the response path; reference the app.Test call and the err variable when adding the failure-specific assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@middleware/hostauthorization/hostauthorization_test.go`:
- Around line 455-457: The test currently calls require.ErrorIs inside the
ErrorHandler closure; move that assertion out of the callback by making
ErrorHandler side-effect-free (return the custom status/body only). Capture the
error returned by app.Test or assign the error to an outer variable inside the
closure (e.g., var handlerErr error; in ErrorHandler set handlerErr = err) and
then after calling app.Test perform require.ErrorIs(t, handlerErr,
ErrForbiddenHost) and any response assertions; reference ErrorHandler,
require.ErrorIs, ErrForbiddenHost, and app.Test when making the change.
---
Duplicate comments:
In `@middleware/hostauthorization/hostauthorization_test.go`:
- Around line 275-279: The test currently returns on any non-nil error from
app.Test(req), which can mask unrelated failures; modify the app.Test(req) error
branch to assert the error is the expected host-related failure (e.g., using
require.ErrorContains(t, err, "host" or "empty host") or an equivalent check
against the Host validation error) before returning, then keep the existing
require.Equal(t, fiber.StatusForbidden, resp.StatusCode) assertion for the
response path; reference the app.Test call and the err variable when adding the
failure-specific assertion.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 2b53a8e5-b6e4-4ba9-a4e6-fa3bc158bd36
📒 Files selected for processing (2)
middleware/hostauthorization/hostauthorization.gomiddleware/hostauthorization/hostauthorization_test.go
|
@gaby - this is now fixed. 💪💪 |
|
Given the current implementation and the distinct purposes, I recommend keeping Here's why:
Therefore, maintaining it as a dedicated middleware seems to be the most robust and flexible approach for this security feature. |
There was a problem hiding this comment.
Pull request overview
Adds a new hostauthorization middleware to validate the incoming Host header against an allowlist to mitigate DNS rebinding / Host-header attacks, with support for exact hosts, subdomain wildcards, CIDR ranges, and an optional dynamic validator.
Changes:
- Introduces
middleware/hostauthorizationwith config, parsing/normalization, and request-time host matching. - Adds a comprehensive test suite plus benchmarks for matching and end-to-end middleware behavior.
- Adds end-user documentation and lists the middleware in
.github/README.md.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| middleware/hostauthorization/hostauthorization.go | Implements allowlist parsing + host matching and the middleware handler. |
| middleware/hostauthorization/config.go | Defines middleware configuration, defaults, and exported error. |
| middleware/hostauthorization/hostauthorization_test.go | Adds unit/integration tests and benchmarks for matching behavior and proxy semantics. |
| docs/middleware/hostauthorization.md | Documents usage, matching/normalization rules, and proxy/RFC notes. |
| .github/README.md | Adds the new middleware to the project’s middleware list. |
|
|
||
| ### Subdomain Wildcards | ||
|
|
||
| A leading dot matches any subdomain but **not** the bare domain itself: |
There was a problem hiding this comment.
This not matching the bare domain, is a weird behavior.
|
|
||
| // Host: api.myapp.com → 200 OK | ||
| // Host: www.myapp.com → 200 OK | ||
| // Host: myapp.com → 403 Forbidden |
There was a problem hiding this comment.
This a confusing behavior. Requiring 2 allow rules
| Useful for services accessed directly by IP (e.g. internal tooling) where the `Host` header will be a raw IP address. This matches the **Host header value** against a CIDR range — it does not filter by client IP address: | ||
|
|
||
| ```go | ||
| app.Use(hostauthorization.New(hostauthorization.Config{ |
There was a problem hiding this comment.
This is kind of confusing with https://docs.gofiber.io/whats_new#trusted-proxies
I rather see the same TrustedProxyConfig here, to avoid duplicated code.
| continue | ||
| } | ||
|
|
||
| if hostIP, cidr, err := net.ParseCIDR(h); err == nil { |
There was a problem hiding this comment.
This is all duplicated logic from TrustedProxyConfig handling. Maybe we should make that a public function that can be used from here
ReneWerner87
left a comment
There was a problem hiding this comment.
Going through the latest version of the branch (3334763), here is a follow-up on top of what's already been discussed. Most of the high-priority code-level findings are in inline comments. The points below are either architectural (don't fit a single line) or reinforcements of things @gaby and others already raised that I think deserve a more concrete recommendation.
IDN / Punycode
Browsers always send internationalised domains as Punycode in the Host header: münchen.example.com arrives as xn--mnchen-3ya.example.com. With the current implementation, configuring "münchen.example.com" in AllowedHosts will never match a real browser request.
There's already precedent in the codebase (req.go, Subdomains()):
if strings.Contains(host, "xn--") {
if u, err := idna.Lookup.ToUnicode(host); err == nil {
host = utilsstrings.ToLower(u)
}
}Suggest normalising both sides (config entries and request hostnames) consistently, ideally to ASCII via idna.Lookup.ToASCII since browsers consistently use Punycode and ASCII comparisons stay alloc-free. At minimum this should be a documented "Known limitations" entry.
TrustedProxyConfig consolidation (reinforcing @gaby)
The CIDR parse and contains-check logic is essentially identical to app.handleTrustedProxy. A shared helper would benefit both modules and keep behaviour consistent (the canonical-CIDR panic exists here but not in handleTrustedProxy). Worth doing, even if as a follow-up PR.
Subdomain wildcard semantics (reinforcing @gaby)
The leading-dot convention that excludes the apex is ergonomically weak and forces two entries for the most common case. Worth deciding now since changing it later is breaking. My preference would be *.myapp.com for subdomains-only and myapp.com for apex (familiar glob syntax), but implicit apex inclusion would also be defensible.
Test gaps
A few that would be worth adding alongside the inline points:
-
AllowedHostsFuncas fallback (with call-counter assertion, see L80 inline) -
IPv6 + port in
AllowedHostsconfig entry ("[::1]:8080"). IPv4 is covered, IPv6 isn't. -
Concurrent requests with
-race. The implementation is read-only after init but the contract should be locked in. -
A scaling benchmark with many wildcards (e.g. 100 entries) so we know when the linear
HasSuffixscan starts mattering. -
A
FuzzNormalizeHosttarget. Trivial to add and standard hygiene for any header parser:func FuzzNormalizeHost(f *testing.F) { f.Add("example.com") f.Add("[::1]:8080") f.Add(".myapp.com") f.Fuzz(func(t *testing.T, input string) { _ = normalizeHost(input) }) }
Sentinel error stability
ErrForbiddenHost is exported, so users can do errors.Is(...). A single test asserting the exact Error() string locks in the textual contract for those who match on the message. Minor but useful.
Scope question
CIDR matching accounts for the bulk of the surface area in this PR (parsing, the non-canonical panic, documentation confusion with TrustedProxyConfig, and the duplicated logic). For an initial merge, dropping CIDR and shipping with exact + subdomain wildcards only would make this a tighter, more obviously-correct module. CIDR can land in a follow-up where it's properly consolidated with the trusted-proxy infrastructure.
Not a blocker, just a thought given how much of the review feedback (mine and @gaby's) orbits the CIDR feature.
Overall really nice work on the iterations so far. The split between matchHost micro-benchmarks and the full HTTP-pipeline benchmarks is a great addition for isolating actual middleware cost.
|
|
||
| // parsedHosts holds the pre-parsed host matching structures. | ||
| type parsedHosts struct { | ||
| exact map[string]bool |
There was a problem hiding this comment.
Suggest using map[string]struct{} instead of map[string]bool. The convention in the rest of the codebase (e.g. TrustProxyConfig.ips map[string]struct{} in app.go) uses zero-byte values for set semantics. make modernize will likely flag this too.
| continue | ||
| } | ||
|
|
||
| if hostIP, cidr, err := net.ParseCIDR(h); err == nil { |
There was a problem hiding this comment.
Silent CIDR typo footgun: a common mistake like "10.0.0/8" (missing octet) silently falls through to an exact-match entry that can never match. Test_ConfigInvalidCIDRTreatedAsExact documents this, but in production it's dangerous because the operator thinks their internal range is allowed when it isn't.
We currently panic on non-canonical CIDRs (e.g. 10.0.0.5/8) but accept syntactically broken ones silently. That asymmetry is confusing. Suggest mirroring app.handleTrustedProxy: if the string contains /, always treat it as a CIDR attempt and either panic or log.Warnf on parse failure so the misconfiguration surfaces.
| func normalizeHost(host string) string { | ||
| // Strip port if present. net.SplitHostPort handles both "host:port" and | ||
| // "[::1]:port" forms, and strips IPv6 brackets as a side effect. | ||
| if h, _, err := net.SplitHostPort(host); err == nil { |
There was a problem hiding this comment.
Hot-path allocation: c.Hostname() already strips the port (via parseAddr in helpers.go), so on real request hostnames net.SplitHostPort always hits the error path, and stdlib allocates a *AddrError for every error. That's one allocation per request.
Suggest a fast path:
if len(host) > 0 && host[0] != '[' && strings.IndexByte(host, ':') < 0 {
host = strings.TrimSuffix(host, ".")
return utilsstrings.ToLower(host)
}
// existing logic for the rare caseWorth a before/after benchmark with the existing Benchmark_matchHost_* to confirm the alloc drops to 0.
| // matchHost checks if the given host matches any of the parsed allowed hosts. | ||
| func matchHost(host string, parsed parsedHosts, allowedHostsFunc func(string) bool) bool { | ||
| // Dynamic validator — checked first so it can override static rules | ||
| if allowedHostsFunc != nil && allowedHostsFunc(host) { |
There was a problem hiding this comment.
This contradicts both the docs and the stated CORS pattern.
The middleware docs say:
AllowedHostsFuncis only called when staticAllowedHostsdon't match
And the PR description claims this mirrors the CORS AllowOriginsFunc pattern, where the func is explicitly a fallback (see middleware/cors/cors.go):
// Run AllowOriginsFunc if the logic for handling the value in 'AllowOrigins'
// does not result in allowOrigin being set.
if allowOrigin == "" && cfg.AllowOriginsFunc != nil && cfg.AllowOriginsFunc(originHeaderRaw) {Performance-wise this matters too: a user-supplied AllowedHostsFunc is the most expensive lookup (the docs even suggest "Look up tenant domains from database"), so calling it on every request before the static map lookup is exactly the wrong default.
Suggest reordering to: exact → wildcard → CIDR → AllowedHostsFunc. That matches the documented behaviour and the CORS convention.
A counter-based test would prevent regressions:
called := 0
fn := func(host string) bool { called++; return false }
// request that matches a static AllowedHosts entry
require.Equal(t, 0, called, "AllowedHostsFunc should not be called when static hosts match")|
|
||
| // CIDR match: parse host as IP and check against CIDR ranges | ||
| if len(parsed.cidrNets) > 0 { | ||
| if ip := net.ParseIP(host); ip != nil { |
There was a problem hiding this comment.
Hot-path allocation: net.ParseIP runs on every request as soon as any CIDR is configured, even for hostnames like api.myapp.com that obviously can't be IPs. ParseIP allocates internally.
A cheap pre-check (first byte must be a digit or :) would skip ParseIP for the typical mixed exact + CIDR config where most requests carry hostnames, not raw IPs:
if len(parsed.cidrNets) > 0 && len(host) > 0 {
if c := host[0]; (c >= '0' && c <= '9') || c == ':' {
if ip := net.ParseIP(host); ip != nil {
// ...
}
}
}|
|
||
| if cfg.ErrorHandler == nil { | ||
| cfg.ErrorHandler = func(c fiber.Ctx, _ error) error { | ||
| return c.Status(fiber.StatusForbidden).SendString("Forbidden") |
There was a problem hiding this comment.
The hardcoded "Forbidden" string drifts from the rest of the framework. Other middleware uses utils.StatusMessage(fiber.StatusForbidden) or returns fiber.NewError(fiber.StatusForbidden) so the response body stays consistent with however Fiber surfaces status text elsewhere (and survives a future refactor without each middleware needing a touch). Minor, but trivially fixable.
|
|
||
| // parseAllowedHosts categorizes AllowedHosts into exact, wildcard, and CIDR groups. | ||
| // Panics on invalid CIDR entries. | ||
| func parseAllowedHosts(hosts []string) parsedHosts { |
There was a problem hiding this comment.
RFC 1035 length validation: parseDomainPattern in domain.go enforces total length ≤ 253 chars, per-label length ≤ 63 chars, and a max label count via maxDomainParts. parseAllowedHosts enforces none of these, so a misconfiguration can put unbounded strings into the matching loop on every request.
For consistency with the rest of the framework's hostname handling (and as a cheap defense against accidental DoS from a malformed config), suggest mirroring those bounds here.
| // - fasthttp rejects the missing Host at the protocol level → app.Test returns an error | ||
| // - Go 1.26+ serializes a default Host value so the request reaches the middleware, | ||
| // which rejects it with 403 Forbidden → app.Test returns a response with no error | ||
| resp, err := app.Test(req) |
There was a problem hiding this comment.
This early return on any non-nil error from app.Test (already flagged by CodeRabbit) makes the test pass for unrelated failures too.
Suggest tightening to assert the error is host-related before returning, e.g.
if err != nil {
require.ErrorContains(t, err, "Host")
return
}Otherwise a future fasthttp change that errors out for a different reason would silently keep this test green.
| - **RFC 9110 Section 7.2** — Host and port are separate components; port is stripped before matching | ||
| - **RFC 9110 Section 17.1** — Origin servers should reject misdirected requests | ||
| - **RFC 9112 Section 3.2** — Requests with missing Host headers should be rejected | ||
| - Returns **403 Forbidden** (not 400) because the request is syntactically valid but semantically unauthorized |
There was a problem hiding this comment.
Worth considering 421 Misdirected Request as the default instead of 403. RFC 9110 §15.5.20 defines it as:
The request was directed at a server that is unable or unwilling to produce an authoritative response for the target URI.
Which fits the "wrong host for this server" semantics better than 403 ("understood, but refused due to permissions"). Cloudflare, Fastly and other CDNs use 421 for exactly this case.
Either response is defensible, but 403 is currently presented here as the only correct option, which I don't think it is. At minimum, 421 deserves a mention as an alternative.
|
|
||
| ## Proxy Support | ||
|
|
||
| The middleware uses Fiber's `c.Hostname()`, which respects `X-Forwarded-Host` when [`TrustProxy`](https://docs.gofiber.io/api/fiber#config) is enabled. When `TrustProxy` is disabled (the default), `X-Forwarded-Host` is ignored and the raw `Host` header is used. |
There was a problem hiding this comment.
Worth a sentence here noting HTTP/2 behavior: clients send :authority instead of Host. fasthttp transparently maps it, so the middleware works the same in both protocols, but operators reading this section currently might assume HTTP/1.1-only.
|
@mutantkeyboard pls check the last comments |
Description
Add a new
hostauthorizationmiddleware that validates the incoming Host header against a configurable allowlist, rejecting unauthorized hosts with 403 Forbidden. This protects against DNS rebinding attackswhere an attacker-controlled domain resolves to the application's internal IP, causing browsers to send requests with a malicious Host header.
The middleware supports exact host matching, subdomain wildcards (.myapp.com), and CIDR ranges (10.0.0.0/8), with an optional dynamic validator function for runtime host resolution. It complements the
Domain()router (#4100) —
Domain()handleshost-basedrouting whilehostauthorizationacts as a security gate.Fixes #4148
Changes introduced
middleware. Next, ErrorHandler follow established Fiber conventions.
dependencies, no state, no caching.
Domain()router.Type of change
Please delete options that are not relevant.
Checklist
Before you submit your pull request, please make sure you meet these requirements:
/docs/directory for Fiber's documentation.Commit formatting
Please use emojis in commit messages for an easy way to identify the purpose or intention of a commit. Check out the emoji cheatsheet here: CONTRIBUTING.md