From ffb40d271f8f56c7fb18cedde0deee02a7d504fa Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:55:26 +0300 Subject: [PATCH] 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 --- core/settings_card.py | 16 ++++++++++++++-- core/tests/test_settings_card.py | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/core/settings_card.py b/core/settings_card.py index 69aed597..9c1853cc 100644 --- a/core/settings_card.py +++ b/core/settings_card.py @@ -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 diff --git a/core/tests/test_settings_card.py b/core/tests/test_settings_card.py index 8e49dde8..eb68bdab 100644 --- a/core/tests/test_settings_card.py +++ b/core/tests/test_settings_card.py @@ -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)."""