Skip to content

Commit b176062

Browse files
gabyReneWerner87
andauthored
🧹 chore: Improve BasicAuth middleware RFC compliance (#3743)
Co-authored-by: RW <rene@gofiber.io>
1 parent 93fbc04 commit b176062

5 files changed

Lines changed: 295 additions & 23 deletions

File tree

docs/middleware/basicauth.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ id: basicauth
44

55
# BasicAuth
66

7-
Basic Authentication middleware for [Fiber](https://github.com/gofiber/fiber) that provides HTTP basic auth. It calls the next handler for valid credentials and returns [`401 Unauthorized`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401) or a custom response for missing or invalid credentials.
7+
Basic Authentication middleware for [Fiber](https://github.com/gofiber/fiber) that provides HTTP basic auth. It calls the next handler for valid credentials and returns [`401 Unauthorized`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401) for missing or invalid credentials, [`400 Bad Request`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400) for malformed `Authorization` headers, or [`431 Request Header Fields Too Large`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/431) when the header exceeds size limits. Credentials may omit Base64 padding as permitted by RFC 7235's `token68` syntax.
88

9-
The default unauthorized response includes the header `WWW-Authenticate: Basic realm="Restricted", charset="UTF-8"`, sets `Cache-Control: no-store`, and adds a `Vary: Authorization` header.
9+
The default unauthorized response includes the header `WWW-Authenticate: Basic realm="Restricted", charset="UTF-8"`, sets `Cache-Control: no-store`, and adds a `Vary: Authorization` header. Only the `UTF-8` charset is supported; any other value will panic.
1010

1111
## Signatures
1212

@@ -98,10 +98,11 @@ Users: map[string]string{
9898
| Next | `func(fiber.Ctx) bool` | Next defines a function to skip this middleware when it returns true. | `nil` |
9999
| Users | `map[string]string` | Users maps usernames to **hashed** passwords (e.g. bcrypt, `{SHA256}`). | `map[string]string{}` |
100100
| Realm | `string` | Realm is a string to define the realm attribute of BasicAuth. The realm identifies the system to authenticate against and can be used by clients to save credentials. | `"Restricted"` |
101-
| Charset | `string` | Charset sent in the `WWW-Authenticate` header, so clients know how credentials are encoded. | `"UTF-8"` |
101+
| Charset | `string` | Charset sent in the `WWW-Authenticate` header. Only `"UTF-8"` is supported (case-insensitive). | `"UTF-8"` |
102102
| HeaderLimit | `int` | Maximum allowed length of the `Authorization` header. Requests exceeding this limit are rejected. | `8192` |
103103
| Authorizer | `func(string, string, fiber.Ctx) bool` | Authorizer defines a function to check the credentials. It will be called with a username, password, and the current context and is expected to return true or false to indicate approval. | `nil` |
104104
| Unauthorized | `fiber.Handler` | Unauthorized defines the response body for unauthorized responses. | `nil` |
105+
| BadRequest | `fiber.Handler` | BadRequest defines the response for malformed `Authorization` headers. | `nil` |
105106

106107
## Default Config
107108

@@ -114,5 +115,6 @@ var ConfigDefault = Config{
114115
HeaderLimit: 8192,
115116
Authorizer: nil,
116117
Unauthorized: nil,
118+
BadRequest: nil,
117119
}
118120
```

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,6 @@ require (
2626
github.com/x448/float16 v0.8.4 // indirect
2727
golang.org/x/net v0.44.0
2828
golang.org/x/sys v0.36.0 // indirect
29-
golang.org/x/text v0.29.0 // indirect
29+
golang.org/x/text v0.29.0
3030
gopkg.in/yaml.v3 v3.0.1 // indirect
3131
)

middleware/basicauth/basicauth.go

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ package basicauth
22

33
import (
44
"encoding/base64"
5+
"errors"
56
"strings"
7+
"unicode"
8+
"unicode/utf8"
69

710
"github.com/gofiber/fiber/v3"
811
utils "github.com/gofiber/utils/v2"
12+
"golang.org/x/text/unicode/norm"
913
)
1014

1115
// The contextKey type is unexported to prevent collisions with context keys defined in
@@ -24,6 +28,8 @@ func New(config Config) fiber.Handler {
2428
// Set default config
2529
cfg := configDefault(config)
2630

31+
var cerr base64.CorruptInputError
32+
2733
// Return new handler
2834
return func(c fiber.Ctx) error {
2935
// Don't execute middleware if Next returns true
@@ -32,20 +38,47 @@ func New(config Config) fiber.Handler {
3238
}
3339

3440
// Get authorization header and ensure it matches the Basic scheme
35-
auth := utils.Trim(c.Get(fiber.HeaderAuthorization), ' ')
36-
if auth == "" || len(auth) > cfg.HeaderLimit {
41+
rawAuth := c.Get(fiber.HeaderAuthorization)
42+
if rawAuth == "" {
3743
return cfg.Unauthorized(c)
3844
}
39-
40-
parts := strings.Fields(auth)
41-
if len(parts) != 2 || !utils.EqualFold(parts[0], basicScheme) {
45+
if len(rawAuth) > cfg.HeaderLimit {
46+
return c.SendStatus(fiber.StatusRequestHeaderFieldsTooLarge)
47+
}
48+
if containsInvalidHeaderChars(rawAuth) {
49+
return cfg.BadRequest(c)
50+
}
51+
auth := strings.Trim(rawAuth, " \t")
52+
if auth == "" {
4253
return cfg.Unauthorized(c)
4354
}
55+
if len(auth) < len(basicScheme) || !utils.EqualFold(auth[:len(basicScheme)], basicScheme) {
56+
return cfg.Unauthorized(c)
57+
}
58+
rest := auth[len(basicScheme):]
59+
if len(rest) < 2 || rest[0] != ' ' || rest[1] == ' ' {
60+
return cfg.BadRequest(c)
61+
}
62+
rest = rest[1:]
63+
if strings.IndexFunc(rest, unicode.IsSpace) != -1 {
64+
return cfg.BadRequest(c)
65+
}
4466

4567
// Decode the header contents
46-
raw, err := base64.StdEncoding.DecodeString(parts[1])
68+
raw, err := base64.StdEncoding.DecodeString(rest)
4769
if err != nil {
48-
return cfg.Unauthorized(c)
70+
if errors.As(err, &cerr) {
71+
raw, err = base64.RawStdEncoding.DecodeString(rest)
72+
}
73+
if err != nil {
74+
return cfg.BadRequest(c)
75+
}
76+
}
77+
if !utf8.Valid(raw) {
78+
return cfg.BadRequest(c)
79+
}
80+
if !norm.NFC.IsNormal(raw) {
81+
raw = norm.NFC.Bytes(raw)
4982
}
5083

5184
// Get the credentials
@@ -60,13 +93,17 @@ func New(config Config) fiber.Handler {
6093
// which is "username:password".
6194
index := strings.Index(creds, ":")
6295
if index == -1 {
63-
return cfg.Unauthorized(c)
96+
return cfg.BadRequest(c)
6497
}
6598

6699
// Get the username and password
67100
username := creds[:index]
68101
password := creds[index+1:]
69102

103+
if containsCTL(username) || containsCTL(password) {
104+
return cfg.BadRequest(c)
105+
}
106+
70107
if cfg.Authorizer(username, password, c) {
71108
c.Locals(usernameKey, username)
72109
return c.Next()
@@ -77,6 +114,16 @@ func New(config Config) fiber.Handler {
77114
}
78115
}
79116

117+
func containsCTL(s string) bool {
118+
return strings.IndexFunc(s, unicode.IsControl) != -1
119+
}
120+
121+
func containsInvalidHeaderChars(s string) bool {
122+
return strings.IndexFunc(s, func(r rune) bool {
123+
return (r < 0x20 && r != '\t') || r == 0x7F || r >= 0x80
124+
}) != -1
125+
}
126+
80127
// UsernameFromContext returns the username found in the context
81128
// returns an empty string if the username does not exist
82129
func UsernameFromContext(c fiber.Ctx) string {

0 commit comments

Comments
 (0)