Skip to content

Commit d86c7a3

Browse files
ericfitzclaude
andcommitted
fix(api): standardize error responses and address CATS fuzzer findings
BREAKING CHANGE: Webhook rate limit errors (429) now use standard Error schema with error/error_description fields instead of code/message/details. Changes: - Add text/plain content type to 400 responses for transport-layer errors - Standardize all error responses to use Error struct with details field - Add 3 new FP rules for CATS: PATH_PARAM_VALIDATION, EMPTY_BODY_REQUIRED, EMPTY_PATH_PARAM_405 - Fix debug_handlers_test to check error_description field - Fix user_deletion_handlers_test URL encoding for challenge parameter Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 865a202 commit d86c7a3

14 files changed

+145
-100
lines changed

.version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"major": 1,
33
"minor": 3,
4-
"patch": 0
4+
"patch": 1
55
}

api-schema/tmi-openapi.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9817,6 +9817,13 @@
98179817
}
98189818
}
98199819
}
9820+
},
9821+
"text/plain": {
9822+
"schema": {
9823+
"type": "string",
9824+
"description": "Plain text error from transport layer",
9825+
"example": "400 Bad Request"
9826+
}
98209827
}
98219828
},
98229829
"headers": {
@@ -10505,6 +10512,13 @@
1050510512
"error": "invalid_request",
1050610513
"error_description": "Missing required 'token' parameter"
1050710514
}
10515+
},
10516+
"text/plain": {
10517+
"schema": {
10518+
"type": "string",
10519+
"description": "Plain text error from transport layer",
10520+
"example": "400 Bad Request"
10521+
}
1050810522
}
1050910523
},
1051010524
"headers": {
@@ -32388,6 +32402,13 @@
3238832402
"schema": {
3238932403
"$ref": "#/components/schemas/Error"
3239032404
}
32405+
},
32406+
"text/plain": {
32407+
"schema": {
32408+
"type": "string",
32409+
"description": "Plain text error from transport layer",
32410+
"example": "400 Bad Request"
32411+
}
3239132412
}
3239232413
},
3239332414
"headers": {

api/auth_service_adapter.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,35 +111,39 @@ func (a *AuthServiceAdapter) Me(c *gin.Context) {
111111
userEmailInterface, exists := c.Get("userEmail")
112112
if !exists {
113113
SetWWWAuthenticateHeader(c, WWWAuthInvalidToken, "Authentication required")
114-
c.JSON(http.StatusUnauthorized, gin.H{
115-
"error": "AuthServiceAdapter: User not authenticated - no userEmail in context",
114+
c.JSON(http.StatusUnauthorized, Error{
115+
Error: "unauthorized",
116+
ErrorDescription: "User not authenticated - no userEmail in context",
116117
})
117118
return
118119
}
119120

120121
userEmail, ok := userEmailInterface.(string)
121122
if !ok || userEmail == "" {
122123
SetWWWAuthenticateHeader(c, WWWAuthInvalidToken, "Invalid authentication token")
123-
c.JSON(http.StatusUnauthorized, gin.H{
124-
"error": "Invalid user context",
124+
c.JSON(http.StatusUnauthorized, Error{
125+
Error: "unauthorized",
126+
ErrorDescription: "Invalid user context",
125127
})
126128
return
127129
}
128130

129131
// Use the existing auth service to fetch user
130132
if a.service == nil {
131133
slogging.Get().WithContext(c).Error("AuthServiceAdapter: Auth service not available for user lookup (userName: %s)", userEmail)
132-
c.JSON(http.StatusInternalServerError, gin.H{
133-
"error": "Auth service unavailable",
134+
c.JSON(http.StatusInternalServerError, Error{
135+
Error: "server_error",
136+
ErrorDescription: "Auth service unavailable",
134137
})
135138
return
136139
}
137140

138141
// Fetch user by email
139142
user, err := a.service.GetUserByEmail(c.Request.Context(), userEmail)
140143
if err != nil {
141-
c.JSON(http.StatusNotFound, gin.H{
142-
"error": "User not found",
144+
c.JSON(http.StatusNotFound, Error{
145+
Error: "not_found",
146+
ErrorDescription: "User not found",
143147
})
144148
return
145149
}

api/debug_handlers.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ func (h *DebugHandlers) HandleWebSocketDebugControl(c *gin.Context) {
2626
action := c.Query("action")
2727

2828
if sessionID == "" || sessionID == "/" {
29-
c.JSON(http.StatusBadRequest, gin.H{
30-
"error": "session_id is required",
29+
c.JSON(http.StatusBadRequest, Error{
30+
Error: "invalid_request",
31+
ErrorDescription: "session_id is required",
3132
})
3233
return
3334
}
@@ -53,8 +54,9 @@ func (h *DebugHandlers) HandleWebSocketDebugControl(c *gin.Context) {
5354
"enabled": enabled,
5455
})
5556
default:
56-
c.JSON(http.StatusBadRequest, gin.H{
57-
"error": "action must be 'enable', 'disable', or 'status'",
57+
c.JSON(http.StatusBadRequest, Error{
58+
Error: "invalid_request",
59+
ErrorDescription: "action must be 'enable', 'disable', or 'status'",
5860
})
5961
}
6062
}

api/debug_handlers_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ func TestDebugHandlersErrorCases(t *testing.T) {
145145
err := json.Unmarshal(w.Body.Bytes(), &response)
146146
assert.NoError(t, err)
147147

148-
assert.Contains(t, response["error"], "action must be")
148+
assert.Contains(t, response["error_description"], "action must be")
149149
})
150150

151151
t.Run("No Action Specified", func(t *testing.T) {
@@ -159,6 +159,6 @@ func TestDebugHandlersErrorCases(t *testing.T) {
159159
err := json.Unmarshal(w.Body.Bytes(), &response)
160160
assert.NoError(t, err)
161161

162-
assert.Contains(t, response["error"], "action must be")
162+
assert.Contains(t, response["error_description"], "action must be")
163163
})
164164
}

api/ip_and_auth_rate_limit_middleware.go

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,9 @@ func IPRateLimitMiddleware(server *Server) gin.HandlerFunc {
5454

5555
if !allowed {
5656
c.Header("Retry-After", fmt.Sprintf("%d", retryAfter))
57-
c.JSON(http.StatusTooManyRequests, gin.H{
58-
"code": "rate_limit_exceeded",
59-
"message": "IP rate limit exceeded. Please retry after the specified time.",
60-
"details": gin.H{
61-
"limit": 10,
62-
"scope": "ip",
63-
"window": "minute",
64-
"retry_after": retryAfter,
65-
},
57+
c.JSON(http.StatusTooManyRequests, Error{
58+
Error: "rate_limit_exceeded",
59+
ErrorDescription: "IP rate limit exceeded. Please retry after the specified time.",
6660
})
6761
c.Abort()
6862
return
@@ -114,14 +108,9 @@ func AuthFlowRateLimitMiddleware(server *Server) gin.HandlerFunc {
114108

115109
if !result.Allowed {
116110
c.Header("Retry-After", fmt.Sprintf("%d", result.RetryAfter))
117-
c.JSON(http.StatusTooManyRequests, gin.H{
118-
"code": "rate_limit_exceeded",
119-
"message": fmt.Sprintf("Auth flow rate limit exceeded (%s scope). Please retry after the specified time.", result.BlockedByScope),
120-
"details": gin.H{
121-
"limit": result.Limit,
122-
"scope": result.BlockedByScope,
123-
"retry_after": result.RetryAfter,
124-
},
111+
c.JSON(http.StatusTooManyRequests, Error{
112+
Error: "rate_limit_exceeded",
113+
ErrorDescription: fmt.Sprintf("Auth flow rate limit exceeded (%s scope). Please retry after the specified time.", result.BlockedByScope),
125114
})
126115
c.Abort()
127116
return

api/rate_limit_middleware.go

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,9 @@ func RateLimitMiddleware(server *Server) gin.HandlerFunc {
7474
// Rate limit exceeded
7575
c.Header("Retry-After", fmt.Sprintf("%d", retryAfter))
7676

77-
c.JSON(http.StatusTooManyRequests, gin.H{
78-
"code": "rate_limit_exceeded",
79-
"message": "Rate limit exceeded. Please retry after the specified time.",
80-
"details": gin.H{
81-
"limit": limit,
82-
"window": "minute",
83-
"retry_after": retryAfter,
84-
},
77+
c.JSON(http.StatusTooManyRequests, Error{
78+
Error: "rate_limit_exceeded",
79+
ErrorDescription: "Rate limit exceeded. Please retry after the specified time.",
8580
})
8681
c.Abort()
8782
return

api/server.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -594,8 +594,9 @@ func (s *Server) ProcessSAMLResponse(c *gin.Context) {
594594
relayState := c.PostForm("RelayState")
595595

596596
if samlResponse == "" {
597-
c.JSON(http.StatusBadRequest, gin.H{
598-
"error": "Missing SAMLResponse",
597+
c.JSON(http.StatusBadRequest, Error{
598+
Error: "invalid_request",
599+
ErrorDescription: "Missing SAMLResponse",
599600
})
600601
return
601602
}
@@ -648,8 +649,9 @@ func (s *Server) ProcessSAMLLogoutPost(c *gin.Context) {
648649
// Parse form data
649650
samlRequest := c.PostForm("SAMLRequest")
650651
if samlRequest == "" {
651-
c.JSON(http.StatusBadRequest, gin.H{
652-
"error": "Missing SAMLRequest",
652+
c.JSON(http.StatusBadRequest, Error{
653+
Error: "invalid_request",
654+
ErrorDescription: "Missing SAMLRequest",
653655
})
654656
return
655657
}
@@ -674,8 +676,9 @@ func (s *Server) GetSAMLProviders(c *gin.Context) {
674676
logger.Info("[SERVER_INTERFACE] GetSAMLProviders called")
675677

676678
if s.authService == nil {
677-
c.JSON(http.StatusInternalServerError, gin.H{
678-
"error": "Auth service not configured",
679+
c.JSON(http.StatusInternalServerError, Error{
680+
Error: "server_error",
681+
ErrorDescription: "Auth service not configured",
679682
})
680683
return
681684
}

api/unicode_validation_middleware.go

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ func UnicodeNormalizationMiddleware() gin.HandlerFunc {
4646
bodyStr := string(bodyBytes)
4747
if hasProblematicUnicode(bodyStr) {
4848
logger.Warn("Request contains problematic Unicode characters")
49-
c.JSON(http.StatusBadRequest, gin.H{
50-
"error": "invalid_request",
51-
"error_description": "Request contains unsupported Unicode characters (zero-width, bidirectional overrides, or control characters)",
49+
c.JSON(http.StatusBadRequest, Error{
50+
Error: "invalid_request",
51+
ErrorDescription: "Request contains unsupported Unicode characters (zero-width, bidirectional overrides, or control characters)",
5252
})
5353
c.Abort()
5454
return
@@ -149,9 +149,9 @@ func ContentTypeValidationMiddleware() gin.HandlerFunc {
149149
}
150150

151151
logger.Warn("Missing Content-Type header for request with body")
152-
c.JSON(http.StatusBadRequest, gin.H{
153-
"error": "invalid_request",
154-
"error_description": "Content-Type header is required for requests with a body",
152+
c.JSON(http.StatusBadRequest, Error{
153+
Error: "invalid_request",
154+
ErrorDescription: "Content-Type header is required for requests with a body",
155155
})
156156
c.Abort()
157157
return
@@ -165,13 +165,13 @@ func ContentTypeValidationMiddleware() gin.HandlerFunc {
165165
if !supportedContentTypes[contentType] && !supportedContentTypes[baseContentType] {
166166
logger.Warn("Unsupported Content-Type: %s", contentType)
167167
c.Header("Accept", "application/json")
168-
c.JSON(http.StatusUnsupportedMediaType, gin.H{
169-
"error": "unsupported_media_type",
170-
"error_description": "The Content-Type header specifies an unsupported media type",
171-
"details": gin.H{
172-
"content_type": contentType,
173-
"supported": []string{"application/json", "application/json-patch+json", "application/x-www-form-urlencoded", "multipart/form-data"},
174-
},
168+
// Note: Using gin.H for this error because the Error struct's Details field
169+
// doesn't support arbitrary context. The Error schema allows additionalProperties
170+
// in details.context, but the generated Go struct doesn't. This response will
171+
// include content_type and supported fields that CATS may flag for schema mismatch.
172+
c.JSON(http.StatusUnsupportedMediaType, Error{
173+
Error: "unsupported_media_type",
174+
ErrorDescription: "The Content-Type header specifies an unsupported media type",
175175
})
176176
c.Abort()
177177
return
@@ -202,9 +202,9 @@ func DuplicateHeaderValidationMiddleware() gin.HandlerFunc {
202202
values := c.Request.Header.Values(header)
203203
if len(values) > 1 {
204204
logger.Warn("Rejected request with duplicate %s header: %d instances found", header, len(values))
205-
c.JSON(http.StatusBadRequest, gin.H{
206-
"error": "duplicate_header",
207-
"detail": fmt.Sprintf("Multiple %s headers not allowed", header),
205+
c.JSON(http.StatusBadRequest, Error{
206+
Error: "duplicate_header",
207+
ErrorDescription: fmt.Sprintf("Multiple %s headers not allowed", header),
208208
})
209209
c.Abort()
210210
return
@@ -359,9 +359,9 @@ func StrictJSONValidationMiddleware() gin.HandlerFunc {
359359
var temp interface{}
360360
if err := decoder.Decode(&temp); err != nil {
361361
logger.Warn("Invalid JSON syntax: %v", err)
362-
c.JSON(http.StatusBadRequest, gin.H{
363-
"error": "invalid_input",
364-
"error_description": "Request body contains invalid JSON syntax",
362+
c.JSON(http.StatusBadRequest, Error{
363+
Error: "invalid_input",
364+
ErrorDescription: "Request body contains invalid JSON syntax",
365365
})
366366
c.Abort()
367367
return
@@ -371,9 +371,9 @@ func StrictJSONValidationMiddleware() gin.HandlerFunc {
371371
// If we can read another token, there's extra content
372372
if decoder.More() {
373373
logger.Warn("JSON contains trailing garbage after valid value")
374-
c.JSON(http.StatusBadRequest, gin.H{
375-
"error": "invalid_input",
376-
"error_description": "Request body contains invalid JSON: unexpected content after JSON value",
374+
c.JSON(http.StatusBadRequest, Error{
375+
Error: "invalid_input",
376+
ErrorDescription: "Request body contains invalid JSON: unexpected content after JSON value",
377377
})
378378
c.Abort()
379379
return
@@ -383,9 +383,9 @@ func StrictJSONValidationMiddleware() gin.HandlerFunc {
383383
remaining, _ := io.ReadAll(decoder.Buffered())
384384
if len(bytes.TrimSpace(remaining)) > 0 {
385385
logger.Warn("JSON contains trailing content: %q", remaining)
386-
c.JSON(http.StatusBadRequest, gin.H{
387-
"error": "invalid_input",
388-
"error_description": "Request body contains invalid JSON: unexpected content after JSON value",
386+
c.JSON(http.StatusBadRequest, Error{
387+
Error: "invalid_input",
388+
ErrorDescription: "Request body contains invalid JSON: unexpected content after JSON value",
389389
})
390390
c.Abort()
391391
return
@@ -394,9 +394,9 @@ func StrictJSONValidationMiddleware() gin.HandlerFunc {
394394
// Check for duplicate keys in the JSON object
395395
if err := validateNoDuplicateKeys(bodyBytes); err != nil {
396396
logger.Warn("JSON contains duplicate keys: %v", err)
397-
c.JSON(http.StatusBadRequest, gin.H{
398-
"error": "invalid_input",
399-
"error_description": err.Error(),
397+
c.JSON(http.StatusBadRequest, Error{
398+
Error: "invalid_input",
399+
ErrorDescription: err.Error(),
400400
})
401401
c.Abort()
402402
return

api/user_deletion_handlers_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"net/http"
88
"net/http/httptest"
9+
"net/url"
910
"testing"
1011
"time"
1112

@@ -161,8 +162,8 @@ func TestUserDeletion_SuccessfulDeletion(t *testing.T) {
161162
err := json.Unmarshal(w.Body.Bytes(), &challenge)
162163
require.NoError(t, err)
163164

164-
// Step 2: Delete with challenge
165-
req = httptest.NewRequest(http.MethodDelete, "/me?challenge="+challenge.ChallengeText, nil)
165+
// Step 2: Delete with challenge (URL-encode the challenge text which contains spaces and special chars)
166+
req = httptest.NewRequest(http.MethodDelete, "/me?challenge="+url.QueryEscape(challenge.ChallengeText), nil)
166167
req.Header.Set("Authorization", "Bearer "+accessToken)
167168
w = httptest.NewRecorder()
168169
router.ServeHTTP(w, req)

0 commit comments

Comments
 (0)