-
-
Notifications
You must be signed in to change notification settings - Fork 2k
🔥 feat: Host auth middleware #4199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 24 commits
fa639a8
7c7b410
a5e97ec
22114d7
b33e427
3ecfc3d
a96d0e8
4b45ec9
698e01b
6dfb453
e0d7971
44f5299
b6c3ca1
eb0219f
3c75222
ecaff03
cf7f9ce
30a0dee
b3cf3ed
b542c02
5f7a435
04bc8e1
39e32f0
803c5c8
2b21039
32c78a6
3334763
ba418c4
32e51a6
bfa353e
5f32ae4
fc757a5
649cfbf
bfbfdbd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| --- | ||
| id: hostauthorization | ||
| --- | ||
|
|
||
| # Host Authorization | ||
|
|
||
| Host authorization middleware for [Fiber](https://github.com/gofiber/fiber) that validates the incoming `Host` header against a configurable allowlist. Protects against [DNS rebinding attacks](https://en.wikipedia.org/wiki/DNS_rebinding) where an attacker-controlled domain resolves to the application's internal IP, causing browsers to send requests with a malicious Host header. | ||
|
|
||
| ## Signatures | ||
|
|
||
| ```go | ||
| func New(config ...Config) fiber.Handler | ||
| ``` | ||
|
|
||
| ## Examples | ||
|
|
||
| Import the middleware package: | ||
|
|
||
| ```go | ||
| import ( | ||
| "github.com/gofiber/fiber/v3" | ||
| "github.com/gofiber/fiber/v3/middleware/hostauthorization" | ||
| ) | ||
| ``` | ||
|
|
||
| Once your Fiber app is initialized, choose one of the following approaches: | ||
|
|
||
| ### Basic Usage | ||
|
|
||
| ```go | ||
| app.Use(hostauthorization.New(hostauthorization.Config{ | ||
| AllowedHosts: []string{"api.myapp.com"}, | ||
| })) | ||
|
|
||
| app.Get("/users", func(c fiber.Ctx) error { | ||
| return c.JSON(getUsers()) | ||
| }) | ||
|
|
||
| // Host: api.myapp.com → 200 OK | ||
| // Host: evil.com → 403 Forbidden | ||
| ``` | ||
|
|
||
| ### Subdomain Wildcards | ||
|
|
||
| A leading dot matches any subdomain but **not** the bare domain itself: | ||
|
|
||
| ```go | ||
| app.Use(hostauthorization.New(hostauthorization.Config{ | ||
| AllowedHosts: []string{".myapp.com"}, | ||
| })) | ||
|
|
||
| // Host: api.myapp.com → 200 OK | ||
| // Host: www.myapp.com → 200 OK | ||
| // Host: myapp.com → 403 Forbidden | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This a confusing behavior. Requiring 2 allow rules |
||
| ``` | ||
|
|
||
| To allow both the bare domain and all subdomains, include both: | ||
|
|
||
| ```go | ||
| AllowedHosts: []string{"myapp.com", ".myapp.com"}, | ||
| ``` | ||
|
|
||
| ### CIDR Ranges | ||
|
|
||
| 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{ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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. |
||
| AllowedHosts: []string{ | ||
| "internal.myapp.com", | ||
| "10.0.0.0/8", // Host header IPs in this range are allowed | ||
| "127.0.0.1", // Host header must be exactly this IP | ||
| }, | ||
| })) | ||
|
|
||
| // Host: internal.myapp.com → 200 OK | ||
| // Host: 10.0.50.3 → 200 OK (Host header IP is in 10.0.0.0/8) | ||
| // Host: 169.254.169.254 → 403 Forbidden (Host header IP not in allowlist) | ||
| ``` | ||
|
|
||
| ### Skipping Health Checks | ||
|
|
||
| Use `Next` to bypass host validation for specific paths: | ||
|
|
||
| ```go | ||
| app.Use(hostauthorization.New(hostauthorization.Config{ | ||
| AllowedHosts: []string{"myapp.com", ".myapp.com"}, | ||
| Next: func(c fiber.Ctx) bool { | ||
| return c.Path() == "/healthz" | ||
| }, | ||
| })) | ||
|
|
||
| // Host: evil.com GET /healthz → 200 OK (skipped) | ||
| // Host: evil.com GET /users → 403 Forbidden | ||
| ``` | ||
|
|
||
| ### Dynamic Validation | ||
|
|
||
| Use `AllowedHostsFunc` for hosts that can't be known at startup: | ||
|
|
||
| ```go | ||
| app.Use(hostauthorization.New(hostauthorization.Config{ | ||
| AllowedHostsFunc: func(host string) bool { | ||
| // Look up tenant domains from database, cache, etc. | ||
| return isRegisteredTenant(host) | ||
| }, | ||
| })) | ||
| ``` | ||
|
|
||
| `AllowedHostsFunc` is only called when static `AllowedHosts` don't match, so you can combine both: | ||
|
|
||
| ```go | ||
| app.Use(hostauthorization.New(hostauthorization.Config{ | ||
| AllowedHosts: []string{"myapp.com", ".myapp.com"}, | ||
| AllowedHostsFunc: func(host string) bool { | ||
| return isRegisteredCustomDomain(host) | ||
| }, | ||
| })) | ||
| ``` | ||
|
|
||
| ### Custom Error Response | ||
|
|
||
| ```go | ||
| app.Use(hostauthorization.New(hostauthorization.Config{ | ||
| AllowedHosts: []string{"myapp.com"}, | ||
| ErrorHandler: func(c fiber.Ctx, err error) error { | ||
| return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ | ||
| "error": "unauthorized host", | ||
| }) | ||
| }, | ||
| })) | ||
| ``` | ||
|
|
||
| ### Combined with Domain() Router | ||
|
|
||
| `hostauthorization` acts as a security gate; [`Domain()`](https://docs.gofiber.io) handles routing: | ||
|
|
||
| ```go | ||
| // Security layer — reject anything not from our hosts | ||
| app.Use(hostauthorization.New(hostauthorization.Config{ | ||
| AllowedHosts: []string{"myapp.com", ".myapp.com"}, | ||
| Next: func(c fiber.Ctx) bool { | ||
| return c.Path() == "/healthz" | ||
| }, | ||
| })) | ||
|
|
||
| // Routing layer — direct allowed hosts to the right handlers | ||
| app.Domain("api.myapp.com").Get("/users", listUsers) | ||
| app.Domain(":tenant.myapp.com").Get("/dashboard", tenantDashboard) | ||
| app.Get("/healthz", healthCheck) | ||
| ``` | ||
|
|
||
| ## Config | ||
|
|
||
| | Property | Type | Description | Default | | ||
| |:-----------------|:------------------------------|:--------------------------------------------------------------------------------------------------|:--------| | ||
| | Next | `func(fiber.Ctx) bool` | Defines a function to skip this middleware when returned true. | `nil` | | ||
| | AllowedHosts | `[]string` | List of permitted hosts. Supports exact match, subdomain wildcard (`.example.com`), and CIDR. | `nil` | | ||
| | AllowedHostsFunc | `func(string) bool` | Dynamic validator called when static AllowedHosts don't match. Receives the normalized hostname. | `nil` | | ||
| | ErrorHandler | `fiber.ErrorHandler` | Called when a request is rejected. Receives `ErrForbiddenHost` as the error. | 403 | | ||
|
|
||
| Either `AllowedHosts` or `AllowedHostsFunc` (or both) must be provided. The middleware panics at startup if neither is set. | ||
|
|
||
| ## Default Config | ||
|
|
||
| ```go | ||
| var ConfigDefault = Config{} | ||
| ``` | ||
|
|
||
| There is no useful default — you must provide at least `AllowedHosts` or `AllowedHostsFunc`. | ||
|
|
||
| ## Host Matching | ||
|
|
||
| The middleware matches hosts in this order: | ||
|
|
||
| 1. **Exact match** — case-insensitive, port and trailing dot stripped | ||
| 2. **Subdomain wildcard** — `".myapp.com"` matches `api.myapp.com` but not `myapp.com` | ||
| 3. **CIDR range** — host is parsed as IP and checked against the network | ||
| 4. **AllowedHostsFunc** — called only if no static rule matched | ||
|
|
||
| The first match wins. If nothing matches, `ErrorHandler` is called. | ||
|
|
||
| ## Host Normalization | ||
|
|
||
| Before matching, the incoming host is normalized: | ||
|
|
||
| - Port is stripped (via `c.Hostname()`) | ||
| - Trailing dot removed (`example.com.` → `example.com`) | ||
| - IPv6 brackets removed (`[::1]` → `::1`) | ||
| - Lowercased | ||
|
|
||
| `AllowedHosts` entries are also lowercased at initialization. | ||
|
|
||
| ## 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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth a sentence here noting HTTP/2 behavior: clients send |
||
|
|
||
| ## RFC Compliance | ||
|
|
||
| - **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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth considering 421 Misdirected Request as the default instead of 403. RFC 9110 §15.5.20 defines it as:
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. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| package hostauthorization | ||
|
|
||
| import ( | ||
| "errors" | ||
|
|
||
| "github.com/gofiber/fiber/v3" | ||
| ) | ||
|
|
||
| // ErrForbiddenHost is returned when the Host header does not match any allowed host. | ||
| var ErrForbiddenHost = errors.New("hostauthorization: forbidden host") | ||
|
|
||
| // Config defines the config for the host authorization middleware. | ||
| type Config struct { | ||
| // Next defines a function to skip this middleware when returned true. | ||
| // Use this to exclude health check endpoints or other paths from host validation. | ||
| // | ||
| // Optional. Default: nil | ||
| Next func(c fiber.Ctx) bool | ||
|
|
||
| // AllowedHostsFunc is a dynamic validator called when static AllowedHosts | ||
| // don't match. Receives the hostname (port stripped, lowercased). | ||
|
ReneWerner87 marked this conversation as resolved.
Outdated
|
||
| // Return true to allow. | ||
| // | ||
| // Optional. Default: nil | ||
| AllowedHostsFunc func(host string) bool | ||
|
|
||
| // ErrorHandler is called when a request is rejected. | ||
| // Receives ErrForbiddenHost as the error. | ||
| // | ||
| // Optional. Default: returns 403 Forbidden with "Forbidden" body. | ||
| ErrorHandler fiber.ErrorHandler | ||
|
|
||
| // AllowedHosts is the list of permitted host values. | ||
| // Supports three match types: | ||
| // - Exact: "api.myapp.com" | ||
| // - Subdomain: ".myapp.com" (leading dot matches any subdomain, NOT the bare domain) | ||
| // - CIDR: "10.0.0.0/8" (matches hosts that are IPs in the range) | ||
| // | ||
| // Required if AllowedHostsFunc is nil. | ||
| AllowedHosts []string | ||
| } | ||
|
mutantkeyboard marked this conversation as resolved.
|
||
|
|
||
| // ConfigDefault is the default config. | ||
| var ConfigDefault = Config{} | ||
|
|
||
| func configDefault(config ...Config) Config { | ||
| cfg := ConfigDefault | ||
| if len(config) > 0 { | ||
| cfg = config[0] | ||
| } | ||
|
|
||
| if len(cfg.AllowedHosts) == 0 && cfg.AllowedHostsFunc == nil { | ||
| panic("hostauthorization: AllowedHosts or AllowedHostsFunc is required") | ||
| } | ||
|
|
||
| if cfg.ErrorHandler == nil { | ||
| cfg.ErrorHandler = func(c fiber.Ctx, _ error) error { | ||
| return c.Status(fiber.StatusForbidden).SendString("Forbidden") | ||
|
ReneWerner87 marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
|
|
||
| return cfg | ||
| } | ||
|
mutantkeyboard marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| package hostauthorization | ||
|
|
||
| import ( | ||
| "net" | ||
| "strings" | ||
|
|
||
| "github.com/gofiber/fiber/v3" | ||
| "github.com/gofiber/utils/v2" | ||
| utilsstrings "github.com/gofiber/utils/v2/strings" | ||
| ) | ||
|
|
||
| // parsedHosts holds the pre-parsed host matching structures. | ||
| type parsedHosts struct { | ||
| exact map[string]bool | ||
|
ReneWerner87 marked this conversation as resolved.
Outdated
|
||
| wildcardSuffixes []string | ||
| cidrNets []*net.IPNet | ||
| } | ||
|
|
||
| // parseAllowedHosts categorizes AllowedHosts into exact, wildcard, and CIDR groups. | ||
| // Panics on invalid CIDR entries. | ||
| func parseAllowedHosts(hosts []string) parsedHosts { | ||
|
mutantkeyboard marked this conversation as resolved.
|
||
| parsed := parsedHosts{ | ||
| exact: make(map[string]bool, len(hosts)), | ||
| } | ||
|
|
||
| for _, h := range hosts { | ||
| h = utils.TrimSpace(h) | ||
| if h == "" { | ||
| continue | ||
| } | ||
| h = normalizeHost(h) | ||
| if h == "" { | ||
| continue | ||
| } | ||
|
|
||
| switch { | ||
| case strings.Contains(h, "/"): | ||
|
mutantkeyboard marked this conversation as resolved.
Outdated
|
||
| // CIDR range | ||
| _, cidr, err := net.ParseCIDR(h) | ||
| if err != nil { | ||
| panic("hostauthorization: invalid CIDR: " + h) | ||
| } | ||
| parsed.cidrNets = append(parsed.cidrNets, cidr) | ||
|
|
||
| case strings.HasPrefix(h, "."): | ||
| // Subdomain wildcard — store with leading dot to avoid allocation in hot path | ||
| parsed.wildcardSuffixes = append(parsed.wildcardSuffixes, h) | ||
|
|
||
| default: | ||
| // Exact match | ||
| parsed.exact[h] = true | ||
| } | ||
| } | ||
|
|
||
| return parsed | ||
| } | ||
|
|
||
| // normalizeHost normalizes a hostname (already port-stripped by c.Hostname()). | ||
| // Strips trailing dot, IPv6 brackets, and lowercases. | ||
| func normalizeHost(host string) string { | ||
| // Strip IPv6 brackets | ||
| host = strings.TrimPrefix(host, "[") | ||
| host = strings.TrimSuffix(host, "]") | ||
|
|
||
| // Strip trailing dot (FQDN normalization) | ||
| host = strings.TrimSuffix(host, ".") | ||
|
|
||
| return utilsstrings.ToLower(host) | ||
| } | ||
|
mutantkeyboard marked this conversation as resolved.
Outdated
|
||
|
|
||
| // matchHost checks if the given host matches any of the parsed allowed hosts. | ||
| func matchHost(host string, parsed parsedHosts, allowedHostsFunc func(string) bool) bool { | ||
| // Exact match | ||
| if parsed.exact[host] { | ||
| return true | ||
| } | ||
|
|
||
| // Subdomain wildcard: ".myapp.com" matches "api.myapp.com" but NOT "myapp.com" | ||
| for _, suffix := range parsed.wildcardSuffixes { | ||
| if strings.HasSuffix(host, suffix) { | ||
| return true | ||
| } | ||
| } | ||
|
|
||
| // CIDR match: parse host as IP and check against CIDR ranges | ||
| if len(parsed.cidrNets) > 0 { | ||
| if ip := net.ParseIP(host); ip != nil { | ||
|
ReneWerner87 marked this conversation as resolved.
Outdated
|
||
| for _, cidr := range parsed.cidrNets { | ||
| if cidr.Contains(ip) { | ||
| return true | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Dynamic validator fallback | ||
| if allowedHostsFunc != nil { | ||
|
mutantkeyboard marked this conversation as resolved.
Outdated
|
||
| return allowedHostsFunc(host) | ||
| } | ||
|
|
||
| return false | ||
| } | ||
|
|
||
| // New creates a new host authorization middleware handler. | ||
| func New(config ...Config) fiber.Handler { | ||
| cfg := configDefault(config...) | ||
| parsed := parseAllowedHosts(cfg.AllowedHosts) | ||
|
|
||
| return func(c fiber.Ctx) error { | ||
| if cfg.Next != nil && cfg.Next(c) { | ||
| return c.Next() | ||
| } | ||
|
|
||
| host := normalizeHost(c.Hostname()) | ||
|
|
||
| if matchHost(host, parsed, cfg.AllowedHostsFunc) { | ||
| return c.Next() | ||
| } | ||
|
|
||
| return cfg.ErrorHandler(c, ErrForbiddenHost) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This not matching the bare domain, is a weird behavior.