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:
Claude
2026-09-12 20:43:16 +00:00
parent 6c23994b2f
commit 7603728e5a
6 changed files with 154 additions and 10 deletions
@@ -182,6 +182,14 @@ const LED_PALETTE = [
// ── Autosave helpers ─────────────────────────────────────────────────────────
const LS_KEY = 'ledmatrix_composer_draft';
//: Element types whose ELEMENT_DEFAULTS carry a `binding` object (see
//: composer-canvas.js). Kept in one place so export validation, the "is this
//: config var still used" check, and the removal warning cannot drift apart
//: the way they did when only dynamic_text/progress_bar were checked and
//: countdown/pips/sparkline/gauge silently went unvalidated.
const BOUND_TYPES = ['dynamic_text', 'progress_bar', 'countdown', 'pips', 'sparkline', 'gauge'];
let _autosaveTimer = null;
function _debouncedAutosave(payload) {
@@ -867,15 +875,30 @@ function composerApp() {
: this.MATRIX_W - bb.w;
// Clear x-anchor so stored x IS the absolute position
if ('xAnchor' in el) el.xAnchor = null;
el.x = Math.round(newX);
if (el.type === 'line') el.x0 = Math.round(newX);
if (el.type === 'line') {
// Translate both endpoints by the same delta so the line moves
// without changing shape -- setting only x0 left x1 behind and
// stretched/shrank the line instead of moving it.
const dx = Math.round(newX) - bb.x;
el.x0 = Math.round(el.x0 + dx);
el.x1 = Math.round(el.x1 + dx);
el.x = el.x0;
} else {
el.x = Math.round(newX);
}
} else {
const newY = mode === 'start' ? 0
: mode === 'center' ? Math.round((this.MATRIX_H - bb.h) / 2)
: this.MATRIX_H - bb.h;
if ('yAnchor' in el) el.yAnchor = null;
el.y = Math.round(newY);
if (el.type === 'line') el.y0 = Math.round(newY);
if (el.type === 'line') {
const dy = Math.round(newY) - bb.y;
el.y0 = Math.round(el.y0 + dy);
el.y1 = Math.round(el.y1 + dy);
el.y = el.y0;
} else {
el.y = Math.round(newY);
}
}
this._snapshot();
this.isDirty = true;
@@ -1162,7 +1185,7 @@ function composerApp() {
removeConfigVar(key) {
const bound = this.elements.filter(
e => e.type === 'dynamic_text' && e.binding?.source === 'config' && e.binding?.key === key
e => BOUND_TYPES.includes(e.type) && e.binding?.source === 'config' && e.binding?.key === key
);
if (bound.length && !confirm(`"${key}" is used by ${bound.length} element(s). Remove anyway?`)) return;
this.dataModel.configVars = this.dataModel.configVars.filter(v => v.key !== key);
@@ -1172,7 +1195,7 @@ function composerApp() {
_isBound(key) {
return this.elements.some(
e => e.type === 'dynamic_text' && e.binding?.source === 'config' && e.binding?.key === key
e => BOUND_TYPES.includes(e.type) && e.binding?.source === 'config' && e.binding?.key === key
);
},
@@ -1306,7 +1329,7 @@ function composerApp() {
if (!this.metadata.author.trim()) { this._setStatus('Author is required', 'error'); return false; }
if (this.elements.length === 0) { this._setStatus('Add at least one element', 'error'); return false; }
const unbound = this.elements.filter(
e => (e.type === 'dynamic_text' || e.type === 'progress_bar') && !e.binding?.key
e => BOUND_TYPES.includes(e.type) && !e.binding?.key
);
if (unbound.length) { this._setStatus(`${unbound.length} element(s) have no variable bound`, 'error'); return false; }
return true;