Skip to content

Commit c2c0298

Browse files
authored
auth: bound metadata header lengths and sanitise resolution errors (#260)
Cap x-subject (≤256 printable ASCII), x-role (≤64), and x-tenant-id (≤1024 bytes / ≤32 entries) so an oversized header is rejected before any handler runs. Tenant resolver errors and unknown roles are now logged server-side at warn and returned to the client as generic InvalidArgument / PermissionDenied messages, instead of leaking raw DB error text or echoing the raw role value. Closes #219.
1 parent 1e2798f commit c2c0298

5 files changed

Lines changed: 228 additions & 13 deletions

File tree

cmd/server/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ func run() int {
165165
authInterceptor = jwtInterceptor
166166
logger.InfoContext(ctx, "JWT auth enabled", "jwks_url", cfg.JWTJWKSURL)
167167
} else {
168-
authInterceptor = auth.NewMetadataInterceptor(tenantResolver(schemaStoreVal))
168+
authInterceptor = auth.NewMetadataInterceptor(tenantResolver(schemaStoreVal), auth.WithMetadataLogger(logger))
169169
logger.InfoContext(ctx, "metadata auth enabled — pass x-subject, x-role, x-tenant-id headers")
170170
}
171171

internal/auth/jwt.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,8 @@ func (i *Interceptor) authenticate(ctx context.Context) (context.Context, error)
168168
switch claims.Role {
169169
case RoleSuperAdmin, RoleAdmin, RoleUser:
170170
default:
171-
return nil, status.Errorf(codes.PermissionDenied, "unknown role: %s", claims.Role)
171+
i.logger.WarnContext(ctx, "jwt auth: unknown role", "role", string(claims.Role), "subject", claims.Subject)
172+
return nil, status.Error(codes.PermissionDenied, "unknown role")
172173
}
173174

174175
// Non-superadmin must have at least one tenant_id.

internal/auth/jwt_test.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package auth
22

33
import (
4+
"bytes"
45
"context"
56
"crypto/rand"
67
"crypto/rsa"
@@ -272,16 +273,33 @@ func TestUnaryInterceptor_CorrectIssuer(t *testing.T) {
272273
}
273274

274275
func TestUnaryInterceptor_UnknownRole(t *testing.T) {
275-
interceptor := newTestInterceptor(t, "")
276+
var logBuf bytes.Buffer
277+
logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
278+
279+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
280+
w.Header().Set("Content-Type", "application/json")
281+
_, _ = w.Write(jwksJSON())
282+
}))
283+
t.Cleanup(srv.Close)
284+
285+
interceptor, err := NewInterceptor(context.Background(), srv.URL, WithLogger(logger))
286+
require.NoError(t, err)
287+
t.Cleanup(interceptor.Close)
276288
unary := interceptor.UnaryInterceptor()
277289

278290
claims := validClaims("editor", "tenant-1")
279291
ctx := ctxWithBearer(signToken(t, claims))
280292

281-
_, err := unary(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/test.Service/Method"}, noopHandler)
293+
_, err = unary(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/test.Service/Method"}, noopHandler)
282294
require.Error(t, err)
283295
assert.Equal(t, codes.PermissionDenied, status.Code(err))
284-
assert.Contains(t, status.Convert(err).Message(), "unknown role")
296+
msg := status.Convert(err).Message()
297+
assert.Equal(t, "unknown role", msg)
298+
assert.NotContains(t, msg, "editor")
299+
300+
logged := logBuf.String()
301+
assert.Contains(t, logged, "unknown role")
302+
assert.Contains(t, logged, "editor")
285303
}
286304

287305
func TestUnaryInterceptor_NonSuperadminMissingTenantID(t *testing.T) {

internal/auth/metadata.go

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package auth
22

33
import (
44
"context"
5+
"log/slog"
56
"strings"
67

78
"github.com/golang-jwt/jwt/v5"
@@ -21,6 +22,12 @@ const (
2122
headerSubject = "x-subject"
2223
headerRole = "x-role"
2324
headerTenantID = "x-tenant-id"
25+
26+
// Bound metadata header sizes to keep auth interceptor allocations small.
27+
maxHeaderLen = 1024
28+
maxSubjectLen = 256
29+
maxTenantIDs = 32
30+
maxRoleLen = 64
2431
)
2532

2633
// TenantResolver resolves a tenant identifier (UUID or name slug) to a UUID.
@@ -31,13 +38,31 @@ type TenantResolver func(ctx context.Context, idOrName string) (string, error)
3138
// instead of JWT tokens. Used when JWT auth is disabled.
3239
type MetadataInterceptor struct {
3340
resolveTenant TenantResolver
41+
logger *slog.Logger
42+
}
43+
44+
// MetadataInterceptorOption configures a MetadataInterceptor.
45+
type MetadataInterceptorOption func(*metadataInterceptorOptions)
46+
47+
type metadataInterceptorOptions struct {
48+
logger *slog.Logger
49+
}
50+
51+
// WithMetadataLogger sets the logger used for sanitised server-side errors.
52+
// Defaults to slog.Default() when unset.
53+
func WithMetadataLogger(l *slog.Logger) MetadataInterceptorOption {
54+
return func(o *metadataInterceptorOptions) { o.logger = l }
3455
}
3556

3657
// NewMetadataInterceptor creates a new metadata-based auth interceptor.
3758
// If resolver is non-nil, tenant IDs in x-tenant-id headers are resolved
3859
// from name slugs to UUIDs before storing in the auth context.
39-
func NewMetadataInterceptor(resolver TenantResolver) *MetadataInterceptor {
40-
return &MetadataInterceptor{resolveTenant: resolver}
60+
func NewMetadataInterceptor(resolver TenantResolver, opts ...MetadataInterceptorOption) *MetadataInterceptor {
61+
o := metadataInterceptorOptions{logger: slog.Default()}
62+
for _, opt := range opts {
63+
opt(&o)
64+
}
65+
return &MetadataInterceptor{resolveTenant: resolver, logger: o.logger}
4166
}
4267

4368
// UnaryInterceptor returns a gRPC unary server interceptor.
@@ -68,35 +93,58 @@ func (m *MetadataInterceptor) StreamInterceptor() grpc.StreamServerInterceptor {
6893
func (m *MetadataInterceptor) extractClaims(ctx context.Context) (context.Context, error) {
6994
md, _ := metadata.FromIncomingContext(ctx)
7095

71-
subject := firstMetadataValue(md, headerSubject)
96+
rawSubject := firstMetadataValue(md, headerSubject)
97+
if len(rawSubject) > maxHeaderLen {
98+
return nil, status.Errorf(codes.InvalidArgument, "%s header exceeds %d bytes", headerSubject, maxHeaderLen)
99+
}
100+
subject := rawSubject
72101
if subject == "" {
73102
return nil, status.Error(codes.Unauthenticated, "x-subject header is required")
74103
}
104+
if len(subject) > maxSubjectLen {
105+
return nil, status.Errorf(codes.InvalidArgument, "%s exceeds %d bytes", headerSubject, maxSubjectLen)
106+
}
107+
if !isPrintableASCII(subject) {
108+
return nil, status.Errorf(codes.InvalidArgument, "%s must be printable ASCII", headerSubject)
109+
}
75110

76-
role := Role(firstMetadataValue(md, headerRole))
111+
rawRole := firstMetadataValue(md, headerRole)
112+
if len(rawRole) > maxRoleLen {
113+
return nil, status.Errorf(codes.InvalidArgument, "%s header exceeds %d bytes", headerRole, maxRoleLen)
114+
}
115+
role := Role(rawRole)
77116
if role == "" {
78117
role = RoleSuperAdmin
79118
}
80119
switch role {
81120
case RoleSuperAdmin, RoleAdmin, RoleUser:
82121
default:
83-
return nil, status.Errorf(codes.PermissionDenied, "unknown role: %s", role)
122+
m.logger.WarnContext(ctx, "metadata auth: unknown role", "role", rawRole, "subject", subject)
123+
return nil, status.Error(codes.PermissionDenied, "unknown role")
84124
}
85125

86126
// Parse tenant IDs — comma-separated in x-tenant-id header.
87127
// If a resolver is configured, slugs are resolved to UUIDs.
88-
var tenantIDs []string
89128
rawTenantID := firstMetadataValue(md, headerTenantID)
129+
if len(rawTenantID) > maxHeaderLen {
130+
return nil, status.Errorf(codes.InvalidArgument, "%s header exceeds %d bytes", headerTenantID, maxHeaderLen)
131+
}
132+
var tenantIDs []string
90133
if rawTenantID != "" {
91-
for _, id := range strings.Split(rawTenantID, ",") {
134+
parts := strings.Split(rawTenantID, ",")
135+
if len(parts) > maxTenantIDs {
136+
return nil, status.Errorf(codes.InvalidArgument, "%s exceeds %d entries", headerTenantID, maxTenantIDs)
137+
}
138+
for _, id := range parts {
92139
id = strings.TrimSpace(id)
93140
if id == "" {
94141
continue
95142
}
96143
if m.resolveTenant != nil {
97144
resolved, err := m.resolveTenant(ctx, id)
98145
if err != nil {
99-
return nil, status.Errorf(codes.InvalidArgument, "failed to resolve tenant %q: %v", id, err)
146+
m.logger.WarnContext(ctx, "metadata auth: tenant resolution failed", "tenant", id, "error", err)
147+
return nil, status.Error(codes.InvalidArgument, "failed to resolve tenant")
100148
}
101149
id = resolved
102150
}
@@ -126,3 +174,14 @@ func firstMetadataValue(md metadata.MD, key string) string {
126174
}
127175
return values[0]
128176
}
177+
178+
// isPrintableASCII reports whether s contains only printable ASCII (0x20–0x7E).
179+
func isPrintableASCII(s string) bool {
180+
for i := 0; i < len(s); i++ {
181+
c := s[i]
182+
if c < 0x20 || c > 0x7E {
183+
return false
184+
}
185+
}
186+
return true
187+
}

internal/auth/metadata_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package auth
22

33
import (
4+
"bytes"
45
"context"
6+
"errors"
7+
"log/slog"
8+
"strings"
59
"testing"
610

711
"github.com/stretchr/testify/assert"
@@ -186,6 +190,139 @@ func TestMetadata_ServerServiceBypass(t *testing.T) {
186190
assert.Equal(t, "ok", resp)
187191
}
188192

193+
func TestMetadata_OversizedSubject(t *testing.T) {
194+
interceptor := NewMetadataInterceptor(nil)
195+
unary := interceptor.UnaryInterceptor()
196+
197+
ctx := ctxWithMetadata(map[string]string{"x-subject": strings.Repeat("a", maxSubjectLen+1)})
198+
_, err := unary(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/test.Service/Method"}, noopHandler)
199+
200+
require.Error(t, err)
201+
assert.Equal(t, codes.InvalidArgument, status.Code(err))
202+
}
203+
204+
func TestMetadata_HugeSubjectHeader(t *testing.T) {
205+
interceptor := NewMetadataInterceptor(nil)
206+
unary := interceptor.UnaryInterceptor()
207+
208+
ctx := ctxWithMetadata(map[string]string{"x-subject": strings.Repeat("a", maxHeaderLen+1)})
209+
_, err := unary(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/test.Service/Method"}, noopHandler)
210+
211+
require.Error(t, err)
212+
assert.Equal(t, codes.InvalidArgument, status.Code(err))
213+
}
214+
215+
func TestMetadata_NonPrintableSubject(t *testing.T) {
216+
interceptor := NewMetadataInterceptor(nil)
217+
unary := interceptor.UnaryInterceptor()
218+
219+
ctx := ctxWithMetadata(map[string]string{"x-subject": "user\x00@example.com"})
220+
_, err := unary(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/test.Service/Method"}, noopHandler)
221+
222+
require.Error(t, err)
223+
assert.Equal(t, codes.InvalidArgument, status.Code(err))
224+
assert.Contains(t, status.Convert(err).Message(), "printable ASCII")
225+
}
226+
227+
func TestMetadata_OversizedRole(t *testing.T) {
228+
interceptor := NewMetadataInterceptor(nil)
229+
unary := interceptor.UnaryInterceptor()
230+
231+
ctx := ctxWithMetadata(map[string]string{
232+
"x-subject": "user@example.com",
233+
"x-role": strings.Repeat("a", maxRoleLen+1),
234+
})
235+
_, err := unary(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/test.Service/Method"}, noopHandler)
236+
237+
require.Error(t, err)
238+
assert.Equal(t, codes.InvalidArgument, status.Code(err))
239+
}
240+
241+
func TestMetadata_OversizedTenantHeader(t *testing.T) {
242+
interceptor := NewMetadataInterceptor(nil)
243+
unary := interceptor.UnaryInterceptor()
244+
245+
ctx := ctxWithMetadata(map[string]string{
246+
"x-subject": "user@example.com",
247+
"x-role": "admin",
248+
"x-tenant-id": strings.Repeat("a", maxHeaderLen+1),
249+
})
250+
_, err := unary(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/test.Service/Method"}, noopHandler)
251+
252+
require.Error(t, err)
253+
assert.Equal(t, codes.InvalidArgument, status.Code(err))
254+
}
255+
256+
func TestMetadata_TooManyTenantIDs(t *testing.T) {
257+
interceptor := NewMetadataInterceptor(nil)
258+
unary := interceptor.UnaryInterceptor()
259+
260+
ids := make([]string, maxTenantIDs+1)
261+
for i := range ids {
262+
ids[i] = "t"
263+
}
264+
ctx := ctxWithMetadata(map[string]string{
265+
"x-subject": "admin@example.com",
266+
"x-role": "admin",
267+
"x-tenant-id": strings.Join(ids, ","),
268+
})
269+
_, err := unary(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/test.Service/Method"}, noopHandler)
270+
271+
require.Error(t, err)
272+
assert.Equal(t, codes.InvalidArgument, status.Code(err))
273+
assert.Contains(t, status.Convert(err).Message(), "exceeds")
274+
}
275+
276+
func TestMetadata_TenantResolverErrorSanitised(t *testing.T) {
277+
var logBuf bytes.Buffer
278+
logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
279+
280+
resolver := func(_ context.Context, _ string) (string, error) {
281+
return "", errors.New("pq: relation \"tenants\" does not exist")
282+
}
283+
interceptor := NewMetadataInterceptor(resolver, WithMetadataLogger(logger))
284+
unary := interceptor.UnaryInterceptor()
285+
286+
ctx := ctxWithMetadata(map[string]string{
287+
"x-subject": "admin@example.com",
288+
"x-role": "admin",
289+
"x-tenant-id": "tenant-123",
290+
})
291+
_, err := unary(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/test.Service/Method"}, noopHandler)
292+
293+
require.Error(t, err)
294+
assert.Equal(t, codes.InvalidArgument, status.Code(err))
295+
msg := status.Convert(err).Message()
296+
assert.Equal(t, "failed to resolve tenant", msg)
297+
assert.NotContains(t, msg, "pq:")
298+
assert.NotContains(t, msg, "tenants")
299+
300+
logged := logBuf.String()
301+
assert.Contains(t, logged, "tenant resolution failed")
302+
assert.Contains(t, logged, "pq: relation")
303+
}
304+
305+
func TestMetadata_UnknownRoleSanitised(t *testing.T) {
306+
var logBuf bytes.Buffer
307+
logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
308+
interceptor := NewMetadataInterceptor(nil, WithMetadataLogger(logger))
309+
unary := interceptor.UnaryInterceptor()
310+
311+
ctx := ctxWithMetadata(map[string]string{
312+
"x-subject": "user@example.com",
313+
"x-role": "secretrole",
314+
})
315+
_, err := unary(ctx, nil, &grpc.UnaryServerInfo{FullMethod: "/test.Service/Method"}, noopHandler)
316+
317+
require.Error(t, err)
318+
assert.Equal(t, codes.PermissionDenied, status.Code(err))
319+
assert.Equal(t, "unknown role", status.Convert(err).Message())
320+
321+
logged := logBuf.String()
322+
assert.Contains(t, logged, "unknown role")
323+
assert.Contains(t, logged, "secretrole")
324+
}
325+
189326
func TestMetadata_StreamInterceptor(t *testing.T) {
190327
interceptor := NewMetadataInterceptor(nil)
191328
stream := interceptor.StreamInterceptor()

0 commit comments

Comments
 (0)