mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-23 03:18:15 +00:00
fix(composer): coerce prefixed colour channels, non-finite numbers, marquee ids
Three more routes into the generated source, plus a fix to one of my own tests
that was checking the wrong branch.
Prefixed colour channels were interpolated raw
----------------------------------------------
Five tuples were built without coercion:
p['fill_tuple'] = f"({el.get('r', 100)}, {el.get('g', 200)}, ...)"
p['empty_tuple'] = f"({el.get('emptyR', 50)}, ...)"
p['label_tuple'] = f"({el.get('labelR', 200)}, ...)"
so progress_bar, pips, sparkline and gauge took arbitrary expressions the same
way width/height did. Confirmed: every one of the five put __import__ into the
generated source. They now go through a new _rgb_tuple helper, which _rgb_expr
also delegates to.
The pre-existing colour test only covered r/g/b on a text element, which is why
the prefixed channels and these four types were never exercised.
Non-finite numbers escaped as a 500
-----------------------------------
json.loads accepts Infinity/-Infinity/NaN by default and Flask's get_json
passes them straight through, so a payload can hand _safe_int a non-finite
float. int(inf) raises OverflowError, which is neither ValueError nor
ComposerInputError, so it escaped both handlers and surfaced as a 500 with a
traceback rather than a 422. Verified end to end through Flask's parser.
Marquee ids reached the source as identifiers
---------------------------------------------
data_key is spliced UNQUOTED into variable names (_{{ data_key }}_text = ...)
and only '-' was normalised. A punctuated id landed in the generated source as
code. ast.parse caught it, so this was not exploitable, but the caller got an
opaque "Generated code has a syntax error" instead of being told the id was
unusable -- the same failure mode as the empty-block bug. Now restricted to
identifier characters and bounded to 64.
The line-anchor test was testing the wrong branch
-------------------------------------------------
test_line_branch_applies_the_anchor_offset searched the whole file for
"case 'line': {". getBoundingBox has one too and comes first, so the assertion
was reading the bounding-box branch: stripping the anchor offset from
_drawElement left all 11 checks green. Both line tests are now scoped to their
own function via tree-sitter, so they cannot be satisfied by the same branch.
Tests: 35 of the injection suite's checks fail against the reverted fixes; the
scoped line test fails when _drawElement's offset is removed. Full suite 4059
passed, the one failure being test_install_lowmem (pre-existing, awaiting #492).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
This commit is contained in:
co-authored by
Claude Opus 5
parent
acc55ef119
commit
37fc1b56b5
@@ -17,6 +17,7 @@ Two ways in, both confirmed against the code before it was fixed:
|
||||
-> f-string interpolated it verbatim: x=0 or __import__("os").system("id")
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -231,3 +232,70 @@ def test_a_clock_without_a_format_uses_the_documented_default():
|
||||
el = {"type": "clock", "id": "c1", "x": 0, "y": 0, "font": "press_start"}
|
||||
src = _generated(_payload(elements=[el]))
|
||||
assert '"%H:%M"' in src, "the %H:%M default did not reach the generated source"
|
||||
|
||||
|
||||
#: (element type, channel key, base element) for colour channels that were
|
||||
#: interpolated raw rather than through _rgb_expr/_safe_int. Prefixed channels
|
||||
#: (emptyR/G/B, labelR/G/B) were the ones the original r/g/b test never reached.
|
||||
RAW_COLOUR_CASES = [
|
||||
("progress_bar", "r", {"x": 0, "y": 0}),
|
||||
("progress_bar", "g", {"x": 0, "y": 0}),
|
||||
("pips", "b", {"x": 0, "y": 0}),
|
||||
("pips", "emptyR", {"x": 0, "y": 0}),
|
||||
("pips", "emptyG", {"x": 0, "y": 0}),
|
||||
("sparkline", "r", {"x": 0, "y": 0}),
|
||||
("gauge", "labelR", {"x": 0, "y": 0, "width": 32, "height": 32}),
|
||||
("gauge", "labelB", {"x": 0, "y": 0, "width": 32, "height": 32}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("etype,channel,base", RAW_COLOUR_CASES)
|
||||
@pytest.mark.parametrize("evil", EXPR_PAYLOADS)
|
||||
def test_a_prefixed_colour_channel_cannot_reach_the_source(etype, channel, base, evil):
|
||||
"""Five tuples were built with f"({el.get('r', 100)}, ...)" -- no coercion.
|
||||
|
||||
The pre-existing colour test only covered r/g/b on a text element, so the
|
||||
prefixed channels and the four other types were never exercised.
|
||||
"""
|
||||
el = {"type": etype, "id": "e1", **base}
|
||||
el[channel] = evil
|
||||
src = _generated(_payload(elements=[el]))
|
||||
assert "__import__" not in src, f"{etype}.{channel}={evil!r} reached the source"
|
||||
assert "os.system" not in src
|
||||
assert not _module_level_code(src)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan")])
|
||||
@pytest.mark.parametrize("field", ["x", "y", "width", "height"])
|
||||
def test_a_non_finite_dimension_does_not_escape_as_an_unhandled_error(field, value):
|
||||
"""json.loads accepts Infinity/NaN and Flask passes them through, so a
|
||||
payload can hand _safe_int a non-finite float. int(inf) raises
|
||||
OverflowError -- neither ValueError nor ComposerInputError -- so it escaped
|
||||
both handlers and surfaced as a 500 with a traceback instead of a 422."""
|
||||
el = {"type": "rectangle", "id": "r1", "x": 0, "y": 0, "width": 10, "height": 8}
|
||||
el[field] = value
|
||||
src = _generated(_payload(elements=[el])) # must not raise
|
||||
# A non-finite value must be replaced by the default, not spelled into the
|
||||
# source. Word-boundary match: "info" in self.logger.info contains "inf".
|
||||
assert not re.search(r"\b(inf|nan|Infinity|NaN)\b", src), \
|
||||
f"{field}={value!r} leaked a non-finite literal into the source"
|
||||
assert not _module_level_code(src)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_id", [
|
||||
'x = __import__("os").system("id") #',
|
||||
"x\nimport os\n_y",
|
||||
"x[0]",
|
||||
"",
|
||||
"a" * 200,
|
||||
])
|
||||
def test_a_marquee_id_cannot_become_code(bad_id):
|
||||
"""data_key is spliced UNQUOTED into variable names
|
||||
(_{{ data_key }}_text = ...), so a non-identifier id landed in the source
|
||||
as code. ast.parse caught it, but the caller then got an opaque
|
||||
"Generated code has a syntax error" rather than being told the id is bad."""
|
||||
el = {"type": "marquee", "id": bad_id, "x": 0, "y": 0, "text": "hi"}
|
||||
src = _generated(_payload(elements=[el])) # must not raise
|
||||
assert "__import__(" not in src
|
||||
assert "os.system(" not in src
|
||||
assert not _module_level_code(src)
|
||||
|
||||
@@ -96,18 +96,28 @@ def test_element_strokes_scale_with_scale():
|
||||
|
||||
|
||||
def test_line_branch_applies_the_anchor_offset():
|
||||
text = CANVAS.read_text()
|
||||
line_branch = text[text.index("case 'line': {"):]
|
||||
"""Scoped to _drawElement.
|
||||
|
||||
getBoundingBox has its own `case 'line': {` and appears first in the file,
|
||||
so searching the whole text found *that* branch -- this assertion passed
|
||||
with the draw branch's anchor offset removed. Verified: stripping it and
|
||||
re-running gave 11/11 green.
|
||||
"""
|
||||
body = _function_source(CANVAS, "_drawElement")
|
||||
line_branch = body[body.index("case 'line': {"):]
|
||||
line_branch = line_branch[:line_branch.index("case 'divider'")]
|
||||
assert "ax - el.x0" in line_branch and "ay - el.y0" in line_branch, \
|
||||
"line drawing ignores xAnchor/yAnchor"
|
||||
assert "el.x0 * s" not in line_branch, "line still drawn from unanchored endpoints"
|
||||
assert "moveTo(el.x0 * s" not in line_branch, \
|
||||
"line still drawn from unanchored endpoints"
|
||||
|
||||
|
||||
def test_line_bounding_box_applies_the_anchor_offset():
|
||||
text = CANVAS.read_text()
|
||||
i = text.index("case 'line':", text.index("getBoundingBox"))
|
||||
box = text[i:i + 400]
|
||||
"""The companion to the above: scoped to getBoundingBox specifically, so
|
||||
the two tests cannot both be satisfied by the same branch."""
|
||||
body = _function_source(CANVAS, "getBoundingBox")
|
||||
box = body[body.index("case 'line'"):]
|
||||
box = box[:box.index("case 'divider'")]
|
||||
assert "ax - el.x0" in box, "line bounding box ignores the anchor"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user