Skip to content

Commit 4379ad2

Browse files
ericfitzclaude
andcommitted
fix(api): address CATS fuzzing errors and improve API hardening
- Add 400 response documentation to GET/DELETE /admin/settings/{key} for invalid key format validation (pattern: ^[a-z][a-z0-9_.]*$) - Add request body rejection to MigrateSystemSettings endpoint for defense-in-depth (endpoint uses only query parameters per OpenAPI) - Add FORM_URLENCODED_JSON_TEST false positive rule to detect when CATS applies JSON validation tests to form-urlencoded endpoints (fixes false positives on /oauth2/revoke per RFC 7009) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent cdfab0d commit 4379ad2

File tree

5 files changed

+101
-2
lines changed

5 files changed

+101
-2
lines changed

.version

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

api-schema/tmi-openapi.json

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37451,6 +37451,39 @@
3745137451
},
3745237452
"500": {
3745337453
"$ref": "#/components/responses/Error"
37454+
},
37455+
"400": {
37456+
"description": "Bad Request - Invalid key format (must match pattern ^[a-z][a-z0-9_.]*$)",
37457+
"content": {
37458+
"application/json": {
37459+
"schema": {
37460+
"$ref": "#/components/schemas/Error"
37461+
}
37462+
}
37463+
},
37464+
"headers": {
37465+
"X-RateLimit-Limit": {
37466+
"description": "Maximum number of requests allowed in the current time window",
37467+
"schema": {
37468+
"type": "integer",
37469+
"example": 1000
37470+
}
37471+
},
37472+
"X-RateLimit-Remaining": {
37473+
"description": "Number of requests remaining in the current time window",
37474+
"schema": {
37475+
"type": "integer",
37476+
"example": 999
37477+
}
37478+
},
37479+
"X-RateLimit-Reset": {
37480+
"description": "Unix epoch seconds when the rate limit window resets",
37481+
"schema": {
37482+
"type": "integer",
37483+
"example": 1735689600
37484+
}
37485+
}
37486+
}
3745437487
}
3745537488
},
3745637489
"security": [
@@ -37720,6 +37753,39 @@
3772037753
},
3772137754
"500": {
3772237755
"$ref": "#/components/responses/Error"
37756+
},
37757+
"400": {
37758+
"description": "Bad Request - Invalid key format (must match pattern ^[a-z][a-z0-9_.]*$)",
37759+
"content": {
37760+
"application/json": {
37761+
"schema": {
37762+
"$ref": "#/components/schemas/Error"
37763+
}
37764+
}
37765+
},
37766+
"headers": {
37767+
"X-RateLimit-Limit": {
37768+
"description": "Maximum number of requests allowed in the current time window",
37769+
"schema": {
37770+
"type": "integer",
37771+
"example": 1000
37772+
}
37773+
},
37774+
"X-RateLimit-Remaining": {
37775+
"description": "Number of requests remaining in the current time window",
37776+
"schema": {
37777+
"type": "integer",
37778+
"example": 999
37779+
}
37780+
},
37781+
"X-RateLimit-Reset": {
37782+
"description": "Unix epoch seconds when the rate limit window resets",
37783+
"schema": {
37784+
"type": "integer",
37785+
"example": 1735689600
37786+
}
37787+
}
37788+
}
3772337789
}
3772437790
},
3772537791
"security": [

api/config_handlers.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,18 @@ func (s *Server) MigrateSystemSettings(c *gin.Context, params MigrateSystemSetti
414414
logger := slogging.Get().WithContext(c)
415415
ctx := c.Request.Context()
416416

417+
// Reject unexpected request bodies for defense-in-depth
418+
// This endpoint uses only query parameters per OpenAPI spec
419+
if c.Request.ContentLength > 0 {
420+
logger.Warn("Unexpected request body in settings migration request")
421+
HandleRequestError(c, &RequestError{
422+
Status: http.StatusBadRequest,
423+
Code: "invalid_request",
424+
Message: "This endpoint does not accept a request body",
425+
})
426+
return
427+
}
428+
417429
// Check admin permissions
418430
isAdmin, err := IsUserAdministrator(c)
419431
if err != nil || !isAdmin {

api/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ var (
2929
// Minor version number
3030
VersionMinor = "282"
3131
// Patch version number
32-
VersionPatch = "1"
32+
VersionPatch = "2"
3333
// GitCommit is the git commit hash from build
3434
GitCommit = "development"
3535
// BuildDate is the build timestamp

scripts/parse-cats-results.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ def extract_test_number(self, test_id: str) -> int:
497497
FP_RULE_TRANSFER_ENCODING = "TRANSFER_ENCODING_501"
498498
FP_RULE_DELETED_RESOURCE_LIST = "DELETED_RESOURCE_LIST"
499499
FP_RULE_REMOVE_FIELDS_ONEOF = "REMOVE_FIELDS_ONEOF"
500+
FP_RULE_FORM_URLENCODED_JSON_TEST = "FORM_URLENCODED_JSON_TEST"
500501

501502
def detect_false_positive(self, data: Dict) -> Tuple[bool, Optional[str]]:
502503
"""
@@ -532,6 +533,7 @@ def detect_false_positive(self, data: Dict) -> Tuple[bool, Optional[str]]:
532533
- TRANSFER_ENCODING_501: Unsupported transfer encoding per RFC 7230
533534
- DELETED_RESOURCE_LIST: List endpoints return 200 with empty array after deletion
534535
- REMOVE_FIELDS_ONEOF: RemoveFields on oneOf endpoints correctly returns 400
536+
- FORM_URLENCODED_JSON_TEST: JSON validation tests on form-urlencoded endpoints
535537
"""
536538
response_code = data.get('response', {}).get('responseCode', 0)
537539
result_reason = (data.get('resultReason') or '').lower()
@@ -890,6 +892,25 @@ def detect_false_positive(self, data: Dict) -> Tuple[bool, Optional[str]]:
890892
if path == '/admin/administrators' and response_code == 400:
891893
return (True, self.FP_RULE_REMOVE_FIELDS_ONEOF)
892894

895+
# 19. JSON validation tests on form-urlencoded endpoints (CATS test design issue)
896+
# CATS fuzzers like MalformedJson, DuplicateKeysFields test JSON-specific issues
897+
# but some endpoints (like /oauth2/revoke per RFC 7009) accept form-urlencoded data.
898+
# When CATS sends form-urlencoded data but applies JSON validation expectations,
899+
# the server correctly handles the form data and returns 200 (per RFC 7009).
900+
# This is correct behavior - the fuzzers are testing the wrong content type.
901+
json_validation_fuzzers = ['MalformedJson', 'DuplicateKeysFields', 'RandomDummyInvalidJsonBody']
902+
if fuzzer in json_validation_fuzzers:
903+
# Check request Content-Type header
904+
request_headers = data.get('request', {}).get('headers') or []
905+
content_type = ''
906+
for h in request_headers:
907+
if h.get('key', '').lower() == 'content-type':
908+
content_type = h.get('value', '').lower()
909+
break
910+
# If form-urlencoded, JSON tests are false positives
911+
if 'application/x-www-form-urlencoded' in content_type:
912+
return (True, self.FP_RULE_FORM_URLENCODED_JSON_TEST)
913+
893914
return (False, None)
894915

895916
def is_false_positive(self, data: Dict) -> bool:

0 commit comments

Comments
 (0)