Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions python/src/agent_manifest/_cose.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,14 +668,74 @@ def _reject_non_json_constant(token: str) -> Any:
)


#: Maximum nesting depth accepted in a COSE payload (DOS-006).
#:
#: A manifest is a shallow document: the deepest path this specification defines
#: is roughly ``artifacts.tool_manifest.tools[].approved_scope``, well under ten.
#: Sixty-four leaves generous headroom for a future revision while staying far
#: below any interpreter's recursion limit.
_MAX_PAYLOAD_NESTING = 64


def _payload_nesting_depth(text: str) -> int:
"""Maximum ``{``/``[`` nesting depth in *text*, ignoring string contents.

Scanned rather than measured after parsing, because the point is to refuse
the work before doing it: a depth check that runs after ``json.loads`` has
already paid for the structure it was supposed to prevent.

String-aware, so a brace inside a member value cannot inflate the count and
make a legitimate manifest look like an attack. Backslash escapes are skipped
so ``"\\\\"`` does not swallow the closing quote.
"""
depth = 0
deepest = 0
in_string = False
escaped = False
for char in text:
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char in "{[":
depth += 1
deepest = max(deepest, depth)
elif char in "}]":
depth -= 1
return deepest


def _parse_payload(payload: bytes) -> dict[str, Any]:
try:
text = payload.decode("utf-8")
except UnicodeDecodeError as exc:
raise CoseStructureError(f"payload is not valid JSON: {exc}") from exc

# Before json.loads, not after. Relying on RecursionError alone made this
# guard platform-dependent: CPython on Linux parses thousands of levels
# without raising, so on that platform there was no bound at all, while
# Windows tripped its own recursion limit and appeared to be protected. An
# explicit bound behaves identically everywhere.
depth = _payload_nesting_depth(text)
if depth > _MAX_PAYLOAD_NESTING:
raise CoseStructureError(
f"payload is nested {depth} levels deep, above the "
f"{_MAX_PAYLOAD_NESTING}-level limit"
)

try:
manifest = json.loads(
payload.decode("utf-8"),
text,
object_pairs_hook=_reject_duplicate_keys,
parse_constant=_reject_non_json_constant,
)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
except json.JSONDecodeError as exc:
raise CoseStructureError(f"payload is not valid JSON: {exc}") from exc
except RecursionError as exc:
# A manifest is untrusted input, so nesting must produce a verdict
Expand Down
38 changes: 38 additions & 0 deletions python/tests/test_cose.py
Original file line number Diff line number Diff line change
Expand Up @@ -1318,3 +1318,41 @@ def test_a_v01_manifest_with_the_v01_envelope_is_unaffected():
assert verify_manifest(manifest, base_context(), store()).result == (
OverallResult.VALID
)


def test_nesting_bound_is_explicit_not_a_recursion_accident():
"""DOS-006 must behave identically on every platform.

Relying on RecursionError made this guard platform-dependent: CPython on
Linux parses thousands of levels without raising, so on that platform there
was no bound at all, while Windows tripped its own recursion limit and
looked protected. Main went red on ubuntu 3.12/3.13 for exactly this.
"""
from agent_manifest._cose import _MAX_PAYLOAD_NESTING, _parse_payload

at_limit = ('{"a":' * (_MAX_PAYLOAD_NESTING - 1)) + '{"version":"0.2"}' + ("}" * (_MAX_PAYLOAD_NESTING - 1))
assert _parse_payload(at_limit.encode()) is not None

too_deep = ('{"a":' * (_MAX_PAYLOAD_NESTING + 1)) + "1" + ("}" * (_MAX_PAYLOAD_NESTING + 1))
with pytest.raises(CoseStructureError, match="levels deep"):
_parse_payload(too_deep.encode())


def test_nesting_scan_ignores_braces_inside_strings():
"""A brace in a member value must not be counted, or a legitimate manifest
with JSON-ish text in a field would be refused as an attack."""
from agent_manifest._cose import _payload_nesting_depth

assert _payload_nesting_depth('{"a": "{{{{{{"}') == 1
assert _payload_nesting_depth('{"a": "\\"} {{{"}') == 1
assert _payload_nesting_depth('{"a": {"b": [1, 2]}}') == 3
assert _payload_nesting_depth("{}") == 1


def test_a_deeply_nested_payload_is_refused_before_it_is_parsed():
"""The refusal must come from the bound, not from whatever json.loads does
with 5000 levels on this particular platform."""
from agent_manifest._cose import _parse_payload

with pytest.raises(CoseStructureError, match="levels deep"):
_parse_payload((('{"a":' * 5000) + "1" + ("}" * 5000)).encode())
Loading