Skip to content

Commit 649cfbf

Browse files
author
Antonio Nesic
committed
Final fixes as proposed by @ReneWerner87
1 parent fc757a5 commit 649cfbf

4 files changed

Lines changed: 176 additions & 242 deletions

File tree

docs/middleware/hostauthorization.md

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ app.Get("/users", func(c fiber.Ctx) error {
4242

4343
### Subdomain Wildcards
4444

45-
A leading dot matches any subdomain but **not** the bare domain itself:
45+
A `*.` prefix matches any subdomain but **not** the bare domain itself:
4646

4747
```go
4848
app.Use(hostauthorization.New(hostauthorization.Config{
49-
AllowedHosts: []string{".myapp.com"},
49+
AllowedHosts: []string{"*.myapp.com"},
5050
}))
5151

5252
// Host: api.myapp.com → 200 OK
@@ -57,34 +57,27 @@ app.Use(hostauthorization.New(hostauthorization.Config{
5757
To allow both the bare domain and all subdomains, include both:
5858

5959
```go
60-
AllowedHosts: []string{"myapp.com", ".myapp.com"},
60+
AllowedHosts: []string{"myapp.com", "*.myapp.com"},
6161
```
6262

63-
### CIDR Ranges
63+
### Internationalized Domain Names (IDN)
6464

65-
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:
65+
Browsers always transmit the `Host` header in ASCII (Punycode) form, so IDN entries in `AllowedHosts` are converted to Punycode at startup. You can configure entries in either form — they are equivalent:
6666

6767
```go
68-
app.Use(hostauthorization.New(hostauthorization.Config{
69-
AllowedHosts: []string{
70-
"internal.myapp.com",
71-
"10.0.0.0/8", // Host header IPs in this range are allowed
72-
"127.0.0.1", // Host header must be exactly this IP
73-
},
74-
}))
75-
76-
// Host: internal.myapp.com → 200 OK
77-
// Host: 10.0.50.3 → 200 OK (Host header IP is in 10.0.0.0/8)
78-
// Host: 169.254.169.254 → 403 Forbidden (Host header IP not in allowlist)
68+
AllowedHosts: []string{"münchen.example.com"} // Unicode
69+
AllowedHosts: []string{"xn--mnchen-3ya.example.com"} // Punycode (what the browser sends)
7970
```
8071

72+
Both match an incoming request whose Host header is `xn--mnchen-3ya.example.com`.
73+
8174
### Skipping Health Checks
8275

8376
Use `Next` to bypass host validation for specific paths:
8477

8578
```go
8679
app.Use(hostauthorization.New(hostauthorization.Config{
87-
AllowedHosts: []string{"myapp.com", ".myapp.com"},
80+
AllowedHosts: []string{"myapp.com", "*.myapp.com"},
8881
Next: func(c fiber.Ctx) bool {
8982
return c.Path() == "/healthz"
9083
},
@@ -111,7 +104,7 @@ app.Use(hostauthorization.New(hostauthorization.Config{
111104

112105
```go
113106
app.Use(hostauthorization.New(hostauthorization.Config{
114-
AllowedHosts: []string{"myapp.com", ".myapp.com"},
107+
AllowedHosts: []string{"myapp.com", "*.myapp.com"},
115108
AllowedHostsFunc: func(host string) bool {
116109
return isRegisteredCustomDomain(host)
117110
},
@@ -120,7 +113,10 @@ app.Use(hostauthorization.New(hostauthorization.Config{
120113

121114
### Custom Error Response
122115

116+
The default response is **403 Forbidden**. **421 Misdirected Request** ([RFC 9110 §15.5.20](https://www.rfc-editor.org/rfc/rfc9110#section-15.5.20)) is a semantically closer choice for "wrong host for this server" — CDNs like Cloudflare and Fastly use it for this case. Either is reasonable; pick one via `ErrorHandler`:
117+
123118
```go
119+
// 403 with a JSON body
124120
app.Use(hostauthorization.New(hostauthorization.Config{
125121
AllowedHosts: []string{"myapp.com"},
126122
ErrorHandler: func(c fiber.Ctx, err error) error {
@@ -129,6 +125,14 @@ app.Use(hostauthorization.New(hostauthorization.Config{
129125
})
130126
},
131127
}))
128+
129+
// 421 Misdirected Request — closer to the RFC-defined semantics
130+
app.Use(hostauthorization.New(hostauthorization.Config{
131+
AllowedHosts: []string{"myapp.com"},
132+
ErrorHandler: func(c fiber.Ctx, _ error) error {
133+
return c.SendStatus(fiber.StatusMisdirectedRequest) // 421
134+
},
135+
}))
132136
```
133137

134138
### Combined with Domain() Router
@@ -138,7 +142,7 @@ app.Use(hostauthorization.New(hostauthorization.Config{
138142
```go
139143
// Security layer — reject anything not from our hosts
140144
app.Use(hostauthorization.New(hostauthorization.Config{
141-
AllowedHosts: []string{"myapp.com", ".myapp.com"},
145+
AllowedHosts: []string{"myapp.com", "*.myapp.com"},
142146
Next: func(c fiber.Ctx) bool {
143147
return c.Path() == "/healthz"
144148
},
@@ -155,8 +159,8 @@ app.Get("/healthz", healthCheck)
155159
| Property | Type | Description | Default |
156160
|:-----------------|:------------------------------|:--------------------------------------------------------------------------------------------------|:--------|
157161
| Next | `func(fiber.Ctx) bool` | Defines a function to skip this middleware when returned true. | `nil` |
158-
| AllowedHosts | `[]string` | List of permitted hosts. Supports exact match, subdomain wildcard (`.example.com`), and CIDR. | `nil` |
159-
| AllowedHostsFunc | `func(string) bool` | Dynamic validator called only when no static AllowedHosts rule matches. Receives the normalized hostname: port stripped, trailing dot removed, IPv6 brackets removed, lowercased. | `nil` |
162+
| AllowedHosts | `[]string` | List of permitted hosts. Supports exact match and subdomain wildcard (`*.example.com`). | `nil` |
163+
| AllowedHostsFunc | `func(string) bool` | Dynamic validator called only when no static AllowedHosts rule matches. Receives the normalized hostname: port stripped, trailing dot removed, IPv6 brackets removed, lowercased, IDN converted to Punycode. | `nil` |
160164
| ErrorHandler | `fiber.ErrorHandler` | Called when a request is rejected. Receives `ErrForbiddenHost` as the error. | 403 |
161165

162166
Either `AllowedHosts` or `AllowedHostsFunc` (or both) must be provided. The middleware panics at startup if neither is set.
@@ -173,23 +177,26 @@ There is no useful default — you must provide at least `AllowedHosts` or `Allo
173177

174178
The middleware matches hosts in this order:
175179

176-
1. **Exact match** — case-insensitive, port and trailing dot stripped
177-
2. **Subdomain wildcard**`".myapp.com"` matches `api.myapp.com` but not `myapp.com`
178-
3. **CIDR range** — host is parsed as IP and checked against the network
179-
4. **AllowedHostsFunc** — called only if no static rule matched
180+
1. **Exact match** — case-insensitive, port and trailing dot stripped, IDN labels in Punycode form
181+
2. **Subdomain wildcard**`"*.myapp.com"` matches `api.myapp.com` but not `myapp.com`
182+
3. **AllowedHostsFunc** — called only if no static rule matched
180183

181184
The first match wins. If nothing matches, `ErrorHandler` is called.
182185

183186
## Host Normalization
184187

185-
Before matching, the incoming host is normalized:
188+
Before matching, both incoming hosts and `AllowedHosts` entries are normalized at startup:
186189

187-
- Port is stripped (via `c.Hostname()`)
190+
- Port is stripped (`example.com:8080``example.com`)
188191
- Trailing dot removed (`example.com.``example.com`)
189192
- IPv6 brackets removed (`[::1]``::1`)
190193
- Lowercased
194+
- IDN labels converted to ASCII/Punycode (`münchen.example.com``xn--mnchen-3ya.example.com`)
195+
- RFC 1035 length limits enforced at startup: ≤253 chars total, ≤63 chars per label (panic on violation)
196+
197+
## Filtering by Client IP
191198

192-
`AllowedHosts` entries are also lowercased at initialization.
199+
This middleware filters by the `Host` *header*, not by the client's source IP. To restrict access by client IP, use Fiber's [`TrustProxy` / `TrustProxyConfig`](https://docs.gofiber.io/whats_new#trusted-proxies) configuration — those are the correct knobs for IP allowlisting and CIDR ranges of trusted proxies.
193200

194201
## Proxy Support
195202

@@ -202,6 +209,7 @@ fasthttp itself is HTTP/1.x only. HTTP/2 support requires an external library (e
202209
- **RFC 9110 Section 7.2** — Host and port are separate components; port is stripped before matching
203210
- **RFC 9110 Section 17.1** — Origin servers should reject misdirected requests
204211
- **RFC 9112 Section 3.2** — Requests with missing Host headers should be rejected
212+
- **RFC 1035**`AllowedHosts` entries are validated against the 253-char total / 63-char per-label limits
205213
- Returns **403 Forbidden** (not 400) because the request is syntactically valid but semantically unauthorized
206214

207215
:::note

middleware/hostauthorization/config.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,13 @@ type Config struct {
3232
ErrorHandler fiber.ErrorHandler
3333

3434
// AllowedHosts is the list of permitted host values.
35-
// Supports three match types:
35+
// Supports two match types:
3636
// - Exact: "api.myapp.com"
37-
// - Subdomain: ".myapp.com" (leading dot matches any subdomain, NOT the bare domain)
38-
// - CIDR: "10.0.0.0/8" (matches hosts that are IPs in the range)
37+
// - Subdomain: "*.myapp.com" (matches any subdomain, NOT the bare domain — list both for apex+subdomains)
3938
//
40-
// Entries containing "/" are always treated as CIDR; a parse failure panics at startup.
39+
// Entries are normalized at startup: port stripped, trailing dot removed,
40+
// lowercased, IDN labels converted to Punycode, RFC 1035 length limits enforced
41+
// (≤253 total / ≤63 per-label).
4142
//
4243
// Required if AllowedHostsFunc is nil.
4344
AllowedHosts []string

middleware/hostauthorization/hostauthorization.go

Lines changed: 75 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,30 @@
11
package hostauthorization
22

33
import (
4+
"fmt"
45
"net"
56
"strings"
67

78
"github.com/gofiber/fiber/v3"
89
"github.com/gofiber/utils/v2"
910
utilsstrings "github.com/gofiber/utils/v2/strings"
11+
"golang.org/x/net/idna"
12+
)
13+
14+
// RFC 1035 length limits.
15+
const (
16+
maxDomainLength = 253
17+
maxLabelLength = 63
1018
)
1119

12-
// parsedHosts holds the pre-parsed host matching structures.
1320
type parsedHosts struct {
1421
exact map[string]struct{}
1522
wildcardSuffixes []string
16-
cidrNets []*net.IPNet
1723
}
1824

19-
// parseAllowedHosts categorizes AllowedHosts into exact, wildcard, and CIDR groups.
20-
// Any entry containing "/" is treated as a CIDR attempt; a parse failure panics so
21-
// misconfigured ranges surface at startup rather than silently becoming an exact entry
22-
// that can never match a real host. Non-canonical CIDRs (host bits set) also panic.
25+
// parseAllowedHosts splits AllowedHosts into exact and wildcard groups,
26+
// normalizing entries (port strip, lowercase, IDN→Punycode) and enforcing
27+
// RFC 1035 length limits. Panics on misconfiguration so it surfaces at startup.
2328
func parseAllowedHosts(hosts []string) parsedHosts {
2429
parsed := parsedHosts{
2530
exact: make(map[string]struct{}, len(hosts)),
@@ -30,97 +35,108 @@ func parseAllowedHosts(hosts []string) parsedHosts {
3035
if h == "" {
3136
continue
3237
}
38+
39+
// Reject the leading-dot form some other tools use; we want "*.example.com".
40+
if len(h) > 1 && h[0] == '.' {
41+
panic("hostauthorization: invalid host " + h + " — subdomain wildcards use the \"*.example.com\" form")
42+
}
43+
44+
isWildcard := strings.HasPrefix(h, "*.")
45+
if isWildcard {
46+
h = h[2:]
47+
}
48+
3349
h = normalizeHost(h)
3450
if h == "" {
3551
continue
3652
}
3753

38-
switch {
39-
case strings.Contains(h, "/"):
40-
hostIP, cidr, err := net.ParseCIDR(h)
41-
if err != nil {
42-
panic("hostauthorization: invalid CIDR entry: " + h)
43-
}
44-
// Reject non-canonical CIDRs (host bits set): "10.0.0.5/8" would
45-
// silently expand to 10.0.0.0/8, allowing far more than intended.
46-
if !hostIP.Equal(cidr.IP) {
47-
panic("hostauthorization: CIDR has host bits set, use canonical form: " + h)
48-
}
49-
parsed.cidrNets = append(parsed.cidrNets, cidr)
50-
case strings.HasPrefix(h, "."):
51-
// Subdomain wildcard — store with leading dot to avoid allocation in hot path
52-
parsed.wildcardSuffixes = append(parsed.wildcardSuffixes, h)
53-
default:
54+
validateHostLength(h)
55+
56+
if isWildcard {
57+
// Stored with leading dot so the hot-path HasSuffix check stays alloc-free.
58+
parsed.wildcardSuffixes = append(parsed.wildcardSuffixes, "."+h)
59+
} else {
5460
parsed.exact[h] = struct{}{}
5561
}
5662
}
5763

5864
return parsed
5965
}
6066

61-
// normalizeHost normalizes a hostname for matching: port stripped, trailing dot
62-
// removed, IPv6 brackets removed, lowercased.
63-
// Safe to call on both c.Hostname() output (already port-stripped) and raw
64-
// AllowedHosts entries (which may include a port like "example.com:8080").
67+
func validateHostLength(host string) {
68+
if len(host) > maxDomainLength {
69+
panic(fmt.Sprintf("hostauthorization: host %q exceeds RFC 1035 maximum of %d characters (%d chars)",
70+
host, maxDomainLength, len(host)))
71+
}
72+
// IPv6 hosts contain colons and aren't dotted labels.
73+
if strings.IndexByte(host, ':') >= 0 {
74+
return
75+
}
76+
for _, label := range strings.Split(host, ".") {
77+
if len(label) > maxLabelLength {
78+
panic(fmt.Sprintf("hostauthorization: host %q has label %q exceeding RFC 1035 limit of %d characters (%d chars)",
79+
host, label, maxLabelLength, len(label)))
80+
}
81+
}
82+
}
83+
84+
// normalizeHost strips port, trailing dot, and IPv6 brackets, lowercases,
85+
// and converts IDN labels to Punycode (matching what browsers send).
6586
func normalizeHost(host string) string {
66-
// Fast path for plain hostnames (no IPv6 brackets, no port).
67-
// net.SplitHostPort allocates a *AddrError on every error path; skipping
68-
// it for the common case avoids one allocation per request.
87+
// Fast path for plain hostnames — avoids net.SplitHostPort's error allocation.
6988
if host != "" && host[0] != '[' && strings.IndexByte(host, ':') < 0 {
7089
host = strings.TrimSuffix(host, ".")
71-
return utilsstrings.ToLower(host)
90+
host = utilsstrings.ToLower(host)
91+
return toPunycode(host)
7292
}
7393

74-
// Handle "[::1]:port", "[::1]", and "host:port" forms.
7594
if h, _, err := net.SplitHostPort(host); err == nil {
7695
host = h
7796
} else {
78-
// No port: strip bare IPv6 brackets (e.g. "[::1]" → "::1").
7997
host = strings.TrimPrefix(host, "[")
8098
host = strings.TrimSuffix(host, "]")
8199
}
82100

83101
host = strings.TrimSuffix(host, ".")
84-
return utilsstrings.ToLower(host)
102+
host = utilsstrings.ToLower(host)
103+
return toPunycode(host)
104+
}
105+
106+
func toPunycode(host string) string {
107+
if host == "" || strings.IndexByte(host, ':') >= 0 || isASCII(host) {
108+
return host
109+
}
110+
if ascii, err := idna.Lookup.ToASCII(host); err == nil {
111+
return ascii
112+
}
113+
// Non-convertible input falls through; it won't match any Punycode entry,
114+
// which is the correct security default.
115+
return host
116+
}
117+
118+
func isASCII(s string) bool {
119+
for i := 0; i < len(s); i++ {
120+
if s[i] >= 0x80 {
121+
return false
122+
}
123+
}
124+
return true
85125
}
86126

87-
// matchHost checks if the given host matches any of the parsed allowed hosts.
88-
// Evaluation order: exact → wildcard → CIDR → AllowedHostsFunc.
89-
// AllowedHostsFunc is a fallback called only when no static rule matches,
90-
// matching the CORS AllowOriginsFunc convention and avoiding unnecessary calls
91-
// to potentially expensive dynamic validators (e.g. database lookups).
127+
// matchHost evaluates exact → wildcard → AllowedHostsFunc.
128+
// The func is a fallback only — never called when a static rule matched.
92129
func matchHost(host string, parsed parsedHosts, allowedHostsFunc func(string) bool) bool {
93-
// Exact match
94130
if _, ok := parsed.exact[host]; ok {
95131
return true
96132
}
97133

98-
// Subdomain wildcard: ".myapp.com" matches "api.myapp.com" but NOT "myapp.com"
99134
for _, suffix := range parsed.wildcardSuffixes {
100135
if strings.HasSuffix(host, suffix) {
101136
return true
102137
}
103138
}
104139

105-
// CIDR match: parse host as IP and check against CIDR ranges.
106-
// Pre-check to skip net.ParseIP for obvious non-IP hostnames (e.g. "api.myapp.com"):
107-
// - IPv4 addresses start with a digit
108-
// - IPv6 addresses always contain ":" (port is already stripped by normalizeHost)
109-
// Regular hostnames won't match either condition.
110-
if len(parsed.cidrNets) > 0 && host != "" {
111-
firstByte := host[0]
112-
if (firstByte >= '0' && firstByte <= '9') || strings.IndexByte(host, ':') >= 0 {
113-
if ip := net.ParseIP(host); ip != nil {
114-
for _, cidr := range parsed.cidrNets {
115-
if cidr.Contains(ip) {
116-
return true
117-
}
118-
}
119-
}
120-
}
121-
}
122-
123-
// Dynamic validator — fallback only; not called when a static rule matched.
124140
if allowedHostsFunc != nil && allowedHostsFunc(host) {
125141
return true
126142
}

0 commit comments

Comments
 (0)