From 48d938d2c751f7dddafdcb6d4dd10fbfa57d874d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:06:41 -0700 Subject: [PATCH 1/6] test(job-analysis): reject unbounded pre-auth headers --- .../tests/test_http_request_budgets.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 services/job-analysis-api/tests/test_http_request_budgets.py diff --git a/services/job-analysis-api/tests/test_http_request_budgets.py b/services/job-analysis-api/tests/test_http_request_budgets.py new file mode 100644 index 000000000..db01a4a13 --- /dev/null +++ b/services/job-analysis-api/tests/test_http_request_budgets.py @@ -0,0 +1,30 @@ +"""Regression contracts for bounded pre-authentication Job Analysis HTTP metadata.""" + +from __future__ import annotations + +import unittest + +from orgmetra_job_analysis_api.auth import AuthenticationFailed +from orgmetra_job_analysis_api.http import _typed_headers + + +class JobAnalysisHttpRequestBudgetTests(unittest.TestCase): + """Keep attacker-controlled request metadata bounded before authentication.""" + + def test_rejects_excessive_header_count_before_normalization(self) -> None: + """Reject a request with more than the reviewed header-frame budget.""" + headers = [(f"x-padding-{index}".encode("ascii"), b"x") for index in range(65)] + + with self.assertRaises(AuthenticationFailed): + _typed_headers({"headers": headers}) + + def test_rejects_excessive_aggregate_header_bytes_before_normalization(self) -> None: + """Reject one oversized header block before lower-casing or authentication.""" + headers = [(b"x-padding", b"x" * 16384)] + + with self.assertRaises(AuthenticationFailed): + _typed_headers({"headers": headers}) + + +if __name__ == "__main__": + unittest.main() From 009ec469bf3b659f49408b763cdf7646b4f371e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:08:21 -0700 Subject: [PATCH 2/6] fix(job-analysis): bound pre-authentication headers --- .../src/orgmetra_job_analysis_api/http.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/services/job-analysis-api/src/orgmetra_job_analysis_api/http.py b/services/job-analysis-api/src/orgmetra_job_analysis_api/http.py index 9e32ba8bb..9723ebfaf 100644 --- a/services/job-analysis-api/src/orgmetra_job_analysis_api/http.py +++ b/services/job-analysis-api/src/orgmetra_job_analysis_api/http.py @@ -35,6 +35,8 @@ _PURPOSE_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") _MAX_UUID_INT = (1 << 128) - 1 _MAX_REQUEST_BODY_BYTES = 1 << 20 +_MAX_REQUEST_HEADERS = 64 +_MAX_REQUEST_HEADER_BYTES = 16384 _ERROR_NEXT_ACTION = { "route_not_found": "Use the documented tenant-scoped job-analysis route and retry.", "method_not_allowed": "Use POST to persist or GET to read one job-analysis snapshot.", @@ -314,17 +316,21 @@ def _optional_uuid(value: object) -> UUID | None: def _typed_headers(scope: Mapping[str, object]) -> dict[bytes, bytes]: - """Return lower-cased singleton headers or reject malformed header frames.""" + """Return one bounded set of lower-cased singleton request headers.""" raw_headers = scope.get("headers", ()) - if not isinstance(raw_headers, Sequence): + if not isinstance(raw_headers, Sequence) or len(raw_headers) > _MAX_REQUEST_HEADERS: raise AuthenticationFailed("request headers are invalid") headers: dict[bytes, bytes] = {} + total_header_bytes = 0 for header in raw_headers: if not isinstance(header, Sequence) or len(header) != 2: raise AuthenticationFailed("request headers are invalid") name, value = header if not isinstance(name, bytes) or not isinstance(value, bytes): raise AuthenticationFailed("request headers are invalid") + total_header_bytes += len(name) + len(value) + if total_header_bytes > _MAX_REQUEST_HEADER_BYTES: + raise AuthenticationFailed("request headers exceed the accepted size") key = name.lower() if key in headers: raise AuthenticationFailed("duplicate request header") @@ -459,4 +465,4 @@ async def _send_json( *extra_headers, ) await send({"type": "http.response.start", "status": status, "headers": list(headers)}) - await send({"type": "http.response.body", "body": body, "more_body": False}) + await send({"type": "http.response.body", "body": body, "more_body": False}) \ No newline at end of file From fde44b1c11682e29505ef9f037460f6daefaae4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:08:59 -0700 Subject: [PATCH 3/6] docs(job-analysis): document bounded request metadata --- services/job-analysis-api/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/services/job-analysis-api/README.md b/services/job-analysis-api/README.md index 5e6322f57..9ab7d2fdc 100644 --- a/services/job-analysis-api/README.md +++ b/services/job-analysis-api/README.md @@ -23,7 +23,15 @@ POST requires `Authorization`, `Content-Type: application/json`, types receive `415 unsupported_media_type` before the body is read. Optional `position_record_id` and `criterion_blueprint_id` may be included and are bound through foreign keys that fail closed when the parent is missing. GET requires -`purpose` and returns the persisted snapshot document. +`Authorization` and `X-Purpose-Code`, accepts no query parameters, and returns +the persisted snapshot document. + +Attacker-controlled request metadata is bounded before bearer authentication. +The transport accepts at most 64 ASGI header frames and at most 16 KiB of +aggregate header-name and header-value bytes before lower-casing or dictionary +allocation. Requests above either budget fail closed as authentication failures; +they do not reach the identity provider, authorization policy, request body, or +persistence boundary. Posted evidence is bounded and unambiguous. The transport stops reading once the cumulative chunked body exceeds 1 MiB and rejects duplicate JSON member names at From 33103c514120f16f9c827105fb8b7fefb4d9603f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:09:41 -0700 Subject: [PATCH 4/6] test(job-analysis): bound pre-authentication request paths --- .../job-analysis-api/tests/test_http_request_budgets.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/services/job-analysis-api/tests/test_http_request_budgets.py b/services/job-analysis-api/tests/test_http_request_budgets.py index db01a4a13..6478398fa 100644 --- a/services/job-analysis-api/tests/test_http_request_budgets.py +++ b/services/job-analysis-api/tests/test_http_request_budgets.py @@ -5,7 +5,7 @@ import unittest from orgmetra_job_analysis_api.auth import AuthenticationFailed -from orgmetra_job_analysis_api.http import _typed_headers +from orgmetra_job_analysis_api.http import _looks_like_snapshot_route, _typed_headers class JobAnalysisHttpRequestBudgetTests(unittest.TestCase): @@ -25,6 +25,12 @@ def test_rejects_excessive_aggregate_header_bytes_before_normalization(self) -> with self.assertRaises(AuthenticationFailed): _typed_headers({"headers": headers}) + def test_rejects_excessive_route_path_before_split_or_uuid_parsing(self) -> None: + """Reject an oversized route-shaped path before allocating split segments.""" + path = f"/v1/tenants/{'x' * 257}/job-analysis-snapshots" + + self.assertFalse(_looks_like_snapshot_route(path)) + if __name__ == "__main__": unittest.main() From dee3020f2d3ab7f957e419ef80f8d42845039afb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:10:25 -0700 Subject: [PATCH 5/6] fix(job-analysis): bound pre-authentication paths --- .../job-analysis-api/src/orgmetra_job_analysis_api/http.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/services/job-analysis-api/src/orgmetra_job_analysis_api/http.py b/services/job-analysis-api/src/orgmetra_job_analysis_api/http.py index 9723ebfaf..09b5c04bb 100644 --- a/services/job-analysis-api/src/orgmetra_job_analysis_api/http.py +++ b/services/job-analysis-api/src/orgmetra_job_analysis_api/http.py @@ -35,6 +35,7 @@ _PURPOSE_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$") _MAX_UUID_INT = (1 << 128) - 1 _MAX_REQUEST_BODY_BYTES = 1 << 20 +_MAX_REQUEST_PATH_CHARACTERS = 256 _MAX_REQUEST_HEADERS = 64 _MAX_REQUEST_HEADER_BYTES = 16384 _ERROR_NEXT_ACTION = { @@ -293,7 +294,9 @@ async def __call__(self, scope: Mapping[str, object], receive: AsgiReceive, send def _looks_like_snapshot_route(path: str) -> bool: - """Recognize collection or item snapshot routes before parsing identifiers.""" + """Recognize only one bounded collection or item snapshot route.""" + if len(path) > _MAX_REQUEST_PATH_CHARACTERS: + return False parts = path.strip("/").split("/") if len(parts) not in {4, 5}: return False @@ -465,4 +468,4 @@ async def _send_json( *extra_headers, ) await send({"type": "http.response.start", "status": status, "headers": list(headers)}) - await send({"type": "http.response.body", "body": body, "more_body": False}) \ No newline at end of file + await send({"type": "http.response.body", "body": body, "more_body": False}) From 0dc4f09cc3c87829ea1e3a0e3dc0188df07ad8cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 15:11:01 -0700 Subject: [PATCH 6/6] docs(job-analysis): document path budget --- services/job-analysis-api/README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/services/job-analysis-api/README.md b/services/job-analysis-api/README.md index 9ab7d2fdc..b5537764b 100644 --- a/services/job-analysis-api/README.md +++ b/services/job-analysis-api/README.md @@ -27,11 +27,13 @@ through foreign keys that fail closed when the parent is missing. GET requires the persisted snapshot document. Attacker-controlled request metadata is bounded before bearer authentication. -The transport accepts at most 64 ASGI header frames and at most 16 KiB of -aggregate header-name and header-value bytes before lower-casing or dictionary -allocation. Requests above either budget fail closed as authentication failures; -they do not reach the identity provider, authorization policy, request body, or -persistence boundary. +The transport rejects paths longer than 256 characters before splitting route +segments or parsing UUIDs, accepts at most 64 ASGI header frames, and accepts at +most 16 KiB of aggregate header-name and header-value bytes before lower-casing +or dictionary allocation. Requests above a header budget fail closed as +authentication failures, while an oversized path fails route recognition; none +reaches the identity provider, authorization policy, request body, or persistence +boundary. Posted evidence is bounded and unambiguous. The transport stops reading once the cumulative chunked body exceeds 1 MiB and rejects duplicate JSON member names at