fix: settings card export -- round 3 found the DoS bound in a new place

extensive_testing round 3 found round 2's fix was itself incomplete: it
bounded row count and per-key value length, but not the JOINED row string
_rows_from_list builds from a dict with many keys. A funpack_list entry
like {"k1": "x"*400, ..., "k150": "x"*400} produced one row whose value had
no length cap of its own -- row count and per-key length both stayed under
every existing budget while the report JSON embedded in the PNG's tEXt
chunk (which scales 1:1 with it) reached 123MB, reproducing round 2's exact
number through a different attack shape, with zero truncation declared
anywhere on the resulting card.

Fixed by running the joined row string back through _short() (closing the
length gap) and capping how many keys get processed per row at 50 before
that join even happens (closing a related CPU-amplification gap: an absurd
key count still cost one f-string+join per key before any length cap could
apply). Added a test reproducing the exact many-key-row shape and asserting
on render_png's actual wall-clock time and output size, not just collect()'s
data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-09-15 21:55:26 +03:00
co-authored by Claude Sonnet 5
parent 7005de86e5
commit ffb40d271f
2 changed files with 35 additions and 2 deletions
+14 -2
View File
@@ -64,6 +64,7 @@ _NOISE_INPUTS = {"control_after_generate"}
# so this is a real limit worth declaring rather than a corner cut short.
_MAX_VALUE_CHARS = 400
_MAX_ROWS = 2000
_MAX_KEYS_PER_LIST_ROW = 50
def _short(value):
@@ -94,8 +95,19 @@ def _rows_from_list(value):
return None
out = []
for i, row in enumerate(rows[:_MAX_ROWS], 1):
parts = [f"{k}={_short(v)}" for k, v in row.items()]
out.append((f"[{i}]", " ".join(parts) if parts else "—"))
# extensive_testing round 3: _short() on each KEY's value bounded
# nothing about the JOINED row -- a dict with many keys produced one
# row whose string had no cap of its own, so row count and per-key
# length both stayed under budget while a single row's value (and
# the report JSON embedded in the PNG's tEXt chunk, which scales
# 1:1 with it) ballooned past 100MB. Also bound how many keys get
# processed at all: a row with an absurd key count still costs one
# f-string+join per key before any length cap ever applies.
items = list(row.items())
parts = [f"{k}={_short(v)}" for k, v in items[:_MAX_KEYS_PER_LIST_ROW]]
if len(items) > _MAX_KEYS_PER_LIST_ROW:
parts.append(f"…(+{len(items) - _MAX_KEYS_PER_LIST_ROW} more keys)")
out.append((f"[{i}]", _short(" ".join(parts)) if parts else "—"))
if len(rows) > _MAX_ROWS:
out.append(("", f"… {len(rows) - _MAX_ROWS} more rows omitted"))
return out # [] for an empty list; the caller collapses that onto one line
+21
View File
@@ -184,6 +184,27 @@ def test_render_png_stays_fast_even_when_both_caps_are_individually_honored():
assert len(png) < 5_000_000
def test_a_funpack_list_row_with_many_keys_does_not_blow_up_the_embed():
"""extensive_testing round 3: _short() bounded each KEY's value but not
the JOINED row -- a dict with 150 keys x 400-char values produced one
row string with no cap of its own. Row count and per-key length both
stayed under budget while the report JSON embedded in the PNG's tEXt
chunk (which scales with it) reached 123MB, with zero truncation
declared anywhere on the card."""
import json
import time
entry = {f"k{i}": "x" * sc._MAX_VALUE_CHARS for i in range(150)}
big_list = json.dumps([entry for _ in range(2000)])
rep = sc.collect([{"id": "s", "node": "X", "inputs": {"list_1": big_list}}], HOST)
text = " ".join(v for _, v in rep["sections"][0]["rows"])
assert len(text) < 2_000_000
start = time.monotonic()
png = sc.render_png(rep, "dark")
elapsed = time.monotonic() - start
assert elapsed < 5.0
assert len(png) < 5_000_000
def test_render_png_stays_fast_when_one_slot_has_many_large_lists():
"""The Finding B composition gap, carried all the way through to the
actual render (67.8s / 123MB before the fix)."""