Skip to content

Commit 3924a62

Browse files
authored
🔥 feat: Validate HTTP headers in RequestID middleware (#3919)
1 parent 0c6cc5d commit 3924a62

File tree

3 files changed

+128
-4
lines changed

3 files changed

+128
-4
lines changed

docs/middleware/requestid.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ app.Use(requestid.New(requestid.Config{
3939
}))
4040
```
4141

42-
If the request already includes the configured header, that value is reused instead of generating a new one.
42+
If the request already includes the configured header, that value is reused instead of generating a new one. The middleware
43+
rejects IDs containing characters outside the visible ASCII range (for example, control characters or obs-text bytes) and
44+
will regenerate the value using up to three attempts from the configured generator (or UUID when no generator is set). When a
45+
custom generator fails to produce a valid ID, the middleware falls back to three UUID attempts to keep headers RFC-compliant
46+
across transports.
4347

4448
Retrieve the request ID
4549

middleware/requestid/requestid.go

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package requestid
22

33
import (
44
"github.com/gofiber/fiber/v3"
5+
"github.com/gofiber/utils/v2"
56
)
67

78
// The contextKey type is unexported to prevent collisions with context keys defined in
@@ -24,10 +25,9 @@ func New(config ...Config) fiber.Handler {
2425
if cfg.Next != nil && cfg.Next(c) {
2526
return c.Next()
2627
}
27-
// Get id from request, else we generate one
28-
rid := c.Get(cfg.Header)
28+
rid := sanitizeRequestID(c.Get(cfg.Header), cfg.Generator)
2929
if rid == "" {
30-
rid = cfg.Generator()
30+
rid = utils.UUID()
3131
}
3232

3333
// Set new id to response header
@@ -41,6 +41,56 @@ func New(config ...Config) fiber.Handler {
4141
}
4242
}
4343

44+
// sanitizeRequestID returns the provided request ID when it is valid, otherwise
45+
// it tries up to three values from the configured generator (or UUID when no
46+
// generator is set), then three UUIDs if a custom generator failed, falling
47+
// back to an empty string when no visible ASCII ID is produced.
48+
func sanitizeRequestID(rid string, generator func() string) string {
49+
if isValidRequestID(rid) {
50+
return rid
51+
}
52+
53+
generatorFn := generator
54+
if generatorFn == nil {
55+
generatorFn = utils.UUID
56+
}
57+
58+
for range 3 {
59+
rid = generatorFn()
60+
if isValidRequestID(rid) {
61+
return rid
62+
}
63+
}
64+
65+
if generator != nil {
66+
for range 3 {
67+
rid = utils.UUID()
68+
if isValidRequestID(rid) {
69+
return rid
70+
}
71+
}
72+
}
73+
74+
return ""
75+
}
76+
77+
// isValidRequestID reports whether the request ID contains only visible ASCII
78+
// characters (0x20–0x7E) and is non-empty.
79+
func isValidRequestID(rid string) bool {
80+
if rid == "" {
81+
return false
82+
}
83+
84+
for i := 0; i < len(rid); i++ {
85+
c := rid[i]
86+
if c < 0x20 || c > 0x7e {
87+
return false
88+
}
89+
}
90+
91+
return true
92+
}
93+
4494
// FromContext returns the request ID from context.
4595
// If there is no request ID, an empty string is returned.
4696
func FromContext(c fiber.Ctx) string {

middleware/requestid/requestid_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,76 @@ func Test_RequestID(t *testing.T) {
3636
require.Equal(t, reqid, resp.Header.Get(fiber.HeaderXRequestID))
3737
}
3838

39+
func Test_RequestID_InvalidHeaderValue(t *testing.T) {
40+
t.Parallel()
41+
42+
rid := sanitizeRequestID("bad\r\nid", func() string {
43+
return "clean-generated-id"
44+
})
45+
46+
require.Equal(t, "clean-generated-id", rid)
47+
}
48+
49+
func Test_RequestID_InvalidGeneratedValue(t *testing.T) {
50+
t.Parallel()
51+
52+
app := fiber.New()
53+
app.Use(New(Config{
54+
Generator: func() string {
55+
return "bad\r\nid"
56+
},
57+
}))
58+
59+
app.Get("/", func(c fiber.Ctx) error {
60+
return c.SendStatus(fiber.StatusOK)
61+
})
62+
63+
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
64+
require.NoError(t, err)
65+
require.Equal(t, fiber.StatusOK, resp.StatusCode)
66+
67+
rid := resp.Header.Get(fiber.HeaderXRequestID)
68+
require.NotEmpty(t, rid)
69+
require.NotContains(t, rid, "\r")
70+
require.NotContains(t, rid, "\n")
71+
require.Len(t, rid, 36, "Fallback should produce a UUID")
72+
}
73+
74+
func Test_isValidRequestID_VisibleASCII(t *testing.T) {
75+
t.Parallel()
76+
77+
require.True(t, isValidRequestID("request-id-09AZaz ~"))
78+
}
79+
80+
func Test_isValidRequestID_Boundaries(t *testing.T) {
81+
t.Parallel()
82+
83+
t.Run("allows space and tilde", func(t *testing.T) {
84+
t.Parallel()
85+
86+
require.True(t, isValidRequestID(" ~"))
87+
})
88+
89+
t.Run("rejects out of range", func(t *testing.T) {
90+
t.Parallel()
91+
92+
require.False(t, isValidRequestID(string([]byte{0x1f})))
93+
require.False(t, isValidRequestID(string([]byte{0x7f})))
94+
})
95+
96+
t.Run("rejects empty", func(t *testing.T) {
97+
t.Parallel()
98+
99+
require.False(t, isValidRequestID(""))
100+
})
101+
}
102+
103+
func Test_isValidRequestID_RejectsObsText(t *testing.T) {
104+
t.Parallel()
105+
106+
require.False(t, isValidRequestID("valid\xff"))
107+
}
108+
39109
// go test -run Test_RequestID_Next
40110
func Test_RequestID_Next(t *testing.T) {
41111
t.Parallel()

0 commit comments

Comments
 (0)