mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
fix(composer): close binding_source code injection, line-align, and project_root UnboundLocalError
Three findings from CodeRabbit's review of 6c23994b, all verified against
current code before fixing:
- manager.py.j2 interpolated binding.source unescaped into a Python comment
(`pass # dynamic_text binding_source "{{ el.binding_source }}" draws
nothing`). A source string with a newline broke out of the comment; a
crafted payload produces a clean, ast.parse-valid `import os` in the
generated plugin (confirmed against the pre-fix template). This is now a
fixed literal comment that never interpolates the value. Live now that
composer_bp is registered. CWE-94.
- _alignElement moved a line's x0 (or y0) to the new position but left x1
(or y1) behind, so aligning a line changed its shape instead of moving
it. Both endpoints now translate by the same delta.
- web_interface/app.py only assigned project_root inside the relative-path
branch of the plugins_dir resolution. An absolute plugin_system.plugins_
directory (a supported config value) hit UnboundLocalError importing the
module at all, since SchemaManager/composer_bp use project_root further
down. Now assigned unconditionally before the branch.
Also extends BOUND_TYPES coverage in composer-app.js (_isBound,
removeConfigVar, _validateBeforeExport) from dynamic_text/progress_bar to
all six element types that carry a binding object (countdown, pips,
sparkline, gauge too) -- found by direct code reading against
ELEMENT_DEFAULTS in composer-canvas.js, not from a review comment. Without
it, those four types could export with an unbound config key with no
validation error, and deleting a config var they used gave no warning.
All four fixes have mutation-checked regression tests (fail against the
reverted code, pass with the fix): test_binding_source_cannot_break_out_of_the_comment_it_lands_in,
test_align_translates_both_line_endpoints_not_just_the_start,
test_app_plugins_dir_resolution.py, test_binding_checks_cover_every_bound_element_type.
Full suite: 4365 passed, 58 skipped, 2 failed -- both the pre-existing
Europe/Kiev/Asia/Calcutta tzdata-alias gap on this sandbox, identical on
origin/main, unrelated to this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"""web_interface/app.py resolves plugins_dir from config at import time.
|
||||
|
||||
`project_root` used to be assigned only inside the `else` branch (the
|
||||
relative-path case). SchemaManager and composer_bp are wired up with
|
||||
`project_root` further down the same module, so a device configured with an
|
||||
*absolute* `plugin_system.plugins_directory` (a supported value -- see
|
||||
CLAUDE.md) hit `UnboundLocalError` importing web_interface.app at all, taking
|
||||
the whole web UI down.
|
||||
|
||||
app.py isn't imported directly by the test suite -- it has real side effects
|
||||
(managers, background state) at import time -- so this extracts the exact
|
||||
resolution block by its stable comment markers and executes it in isolation,
|
||||
the same way the rest of the suite validates generated/templated code rather
|
||||
than re-implementing it.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
APP_PY = Path(__file__).resolve().parent.parent / "web_interface/app.py"
|
||||
|
||||
START_MARKER = "# Resolve plugin directory - handle both absolute and relative paths\n"
|
||||
END_MARKER = "\nplugin_manager = PluginManager("
|
||||
|
||||
|
||||
def _resolution_block() -> str:
|
||||
src = APP_PY.read_text()
|
||||
start = src.index(START_MARKER)
|
||||
end = src.index(END_MARKER, start)
|
||||
return src[start:end]
|
||||
|
||||
|
||||
def test_resolution_block_still_matches_expected_markers():
|
||||
"""If app.py is restructured enough that these markers move, the exec
|
||||
below would silently test nothing -- fail loudly instead."""
|
||||
block = _resolution_block()
|
||||
assert "plugins_dir" in block and "os.path.isabs" in block
|
||||
|
||||
|
||||
def test_absolute_plugins_directory_does_not_raise_unboundlocalerror():
|
||||
import os
|
||||
ns = {"os": os, "Path": Path, "__file__": str(APP_PY),
|
||||
"plugins_dir_name": "/opt/ledmatrix-plugins"}
|
||||
exec(compile(_resolution_block(), str(APP_PY), "exec"), ns) # noqa: S102
|
||||
assert ns["project_root"] == APP_PY.parent.parent
|
||||
assert ns["plugins_dir"] == Path("/opt/ledmatrix-plugins")
|
||||
|
||||
|
||||
def test_relative_plugins_directory_still_resolves_under_project_root():
|
||||
import os
|
||||
ns = {"os": os, "Path": Path, "__file__": str(APP_PY),
|
||||
"plugins_dir_name": "plugin-repos"}
|
||||
exec(compile(_resolution_block(), str(APP_PY), "exec"), ns) # noqa: S102
|
||||
assert ns["plugins_dir"] == ns["project_root"] / "plugin-repos"
|
||||
@@ -17,6 +17,7 @@ has no branch for, and the template emits a `pass` fallback so a type added to
|
||||
the canvas before its branch exists degrades to a no-op instead of a broken
|
||||
plugin.
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -98,4 +99,26 @@ def test_dynamic_text_with_non_config_binding_does_not_break_generation(wrapper)
|
||||
"binding": {"source": "live", "key": "temperature"}, **wrapper}
|
||||
files = generate(element) # must not raise ComposerInputError
|
||||
assert "manager.py" in files
|
||||
assert 'binding_source "live" draws nothing' in files["manager.py"]
|
||||
assert "non-config dynamic_text binding draws nothing" in files["manager.py"]
|
||||
|
||||
|
||||
def test_binding_source_cannot_break_out_of_the_comment_it_lands_in():
|
||||
"""binding.source used to be interpolated straight into a Python comment
|
||||
(`pass # dynamic_text binding_source "{{ el.binding_source }}" draws
|
||||
nothing`) with no escaping. A source string carrying a newline closed the
|
||||
comment, and text on the following line(s), indented to match, became a
|
||||
real statement in the generated plugin -- CWE-94, and live once
|
||||
composer_bp was registered (confirmed: this exact payload produces a
|
||||
manager.py containing a clean, ast.parse-valid `import os` against the
|
||||
pre-fix template). The comment is now a fixed literal that never
|
||||
interpolates the value at all.
|
||||
"""
|
||||
payload = "foo\n import os\n os.system('id') # "
|
||||
element = {"type": "dynamic_text", "x": 0, "y": 0, "color": "#ffffff",
|
||||
"binding": {"source": payload, "key": "temperature"}}
|
||||
files = generate(element)
|
||||
src = files["manager.py"]
|
||||
assert "import os" not in src
|
||||
assert "os.system" not in src
|
||||
assert "non-config dynamic_text binding draws nothing" in src
|
||||
ast.parse(src) # belt and braces: generate() already enforces this
|
||||
|
||||
@@ -187,6 +187,20 @@ def test_align_clears_the_anchor_and_moves_line_endpoints():
|
||||
"_alignElement no longer moves line endpoints"
|
||||
|
||||
|
||||
def test_align_translates_both_line_endpoints_not_just_the_start():
|
||||
"""Setting only x0 (or y0) left x1/y1 behind, so aligning a line changed
|
||||
its shape instead of moving it -- e.g. a line from x0=20 to x1=50 aligned
|
||||
right became x0=98, x1=50, stretching rather than translating it. Both
|
||||
endpoints must move by the same delta."""
|
||||
body = _method_source(APP, "_alignElement")
|
||||
x_branch = body[body.index("if (axis === 'x')"):body.index("} else {")]
|
||||
y_branch = body[body.index("} else {"):]
|
||||
assert "el.x1" in x_branch, \
|
||||
"_alignElement moves x0 but not x1 -- a line's shape changes, not its position"
|
||||
assert "el.y1" in y_branch, \
|
||||
"_alignElement moves y0 but not y1 -- a line's shape changes, not its position"
|
||||
|
||||
|
||||
def test_divider_stroke_is_centered_in_led_pixels():
|
||||
"""A 0.5 canvas-pixel offset (correct only at SCALE=1) was applied after
|
||||
scaling instead of before it, so at SCALE>1 the stroke bled into the
|
||||
@@ -215,3 +229,35 @@ def test_gauge_radii_are_clamped_to_zero():
|
||||
"gauge y-radius is not clamped to zero"
|
||||
assert "rx - lwPx / 2" not in re.sub(r"Math\.max\(0,\s*rx\s*-\s*lwPx\s*/\s*2\)", "", gauge_branch), \
|
||||
"an unclamped gauge radius is still passed to ctx.ellipse()"
|
||||
|
||||
|
||||
#: ELEMENT_DEFAULTS in composer-canvas.js gives exactly these types a
|
||||
#: `binding` object; verified against that file rather than assumed.
|
||||
BOUND_TYPES = ["dynamic_text", "progress_bar", "countdown", "pips", "sparkline", "gauge"]
|
||||
|
||||
|
||||
def test_bound_types_constant_lists_every_bound_element_type():
|
||||
"""ELEMENT_DEFAULTS in composer-canvas.js gives dynamic_text, progress_bar,
|
||||
countdown, pips, sparkline and gauge a `binding` object. If a type is added
|
||||
to (or removed from) that list without updating APP's BOUND_TYPES, the
|
||||
checks below drift out of sync silently -- this pins the two together."""
|
||||
src = APP.read_text()
|
||||
match = re.search(r"const BOUND_TYPES = \[([^\]]*)\];", src)
|
||||
assert match, "BOUND_TYPES constant not found in composer-app.js"
|
||||
declared = {t.strip().strip("'\"") for t in match.group(1).split(",") if t.strip()}
|
||||
assert declared == set(BOUND_TYPES), \
|
||||
f"BOUND_TYPES {declared} does not match the bound element types {set(BOUND_TYPES)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["_isBound", "removeConfigVar", "_validateBeforeExport"])
|
||||
def test_binding_checks_cover_every_bound_element_type(method):
|
||||
"""Only dynamic_text and progress_bar were checked, so a countdown, pips,
|
||||
sparkline or gauge element with an empty binding passed export validation
|
||||
silently, and deleting a config var still used by one of them gave no
|
||||
warning -- the plugin would read a now-missing key at runtime with no
|
||||
indication why."""
|
||||
body = _method_source(APP, method)
|
||||
assert "BOUND_TYPES.includes(e.type)" in body, \
|
||||
f"{method} does not check every bound element type via BOUND_TYPES"
|
||||
assert "e.type === 'dynamic_text'" not in body, \
|
||||
f"{method} still hardcodes only dynamic_text instead of BOUND_TYPES"
|
||||
|
||||
Reference in New Issue
Block a user