mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-22 10:58:15 +00:00
fix(composer): recognisable path containment, and define md:inline
Follow-up to the previous commit, which made the CodeQL count worse
rather than better: 19 alerts became 22. Two mistakes.
First, the containment check used `base not in candidate.parents`.
That is correct Python but not a form static analysis recognises, so
every path-injection alert stayed and _plugin_dir itself picked up two
more. It now uses os.path.realpath plus os.path.commonpath, which is
both the documented sanitiser shape and stricter than the obvious
alternative: "/x/plugins-evil" startswith "/x/plugins" but is a
different directory, and there is now a test that fails if anyone
swaps commonpath for startswith.
Second, raising ComposerInputError from _plugin_dir and returning
str(exc) added two new py/stack-trace-exposure alerts -- CodeQL flags
exception text reaching a response regardless of the exception's type.
_plugin_dir returns None instead and the three handlers answer with a
fixed literal. There is nothing a caller needs there beyond "that id is
not ok".
Also defines .md\:inline in app.css. composer.html marks five toolbar
button labels `hidden md:inline`, and the class was never defined, so
those labels were hidden at every width and the buttons stayed
icon-only. main's test_web_static_audit.py catches it -- the branch
predates that test, which is why it only surfaced now that CI checks
the merge:
Responsive utility classes referenced in templates but never
defined in app.css (they silently no-op): ['md:inline']
Verified against the merged state -- main's app.css plus this one line,
audited against this branch's templates: 3 passed. The other twelve
classes the audit flags locally are defined on main and are artifacts of
this branch being 54 commits behind.
33 containment tests. Mutation-checked twice: removing the containment
lets eight payloads escape, including /etc/passwd and
plugin/../../../../../../etc/shadow; swapping commonpath for startswith
fails the sibling-prefix test.
Not addressed: three py/stack-trace-exposure alerts on the
_generate_plugin_files handlers. Those return str(exc) for
ComposerInputError, whose seven raise sites are all authored literals
("Author is required.", "Config variable key X is not a valid Python
identifier."). Suppressing them means replacing useful validation
feedback with a generic string, which is a real cost to the user for a
scanner's benefit. Worth a decision rather than a silent downgrade.
The 5 test_web_api.py failures are pre-existing on this branch --
identical counts with these changes stashed.
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
e499efb1f0
commit
79ba93f5a6
@@ -45,19 +45,18 @@ def plugins_dir(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
@pytest.mark.parametrize("payload", TRAVERSAL)
|
@pytest.mark.parametrize("payload", TRAVERSAL)
|
||||||
def test_traversal_payloads_are_refused(plugins_dir, payload):
|
def test_traversal_payloads_are_refused(plugins_dir, payload):
|
||||||
with pytest.raises(C.ComposerInputError):
|
assert C._plugin_dir(payload) is None
|
||||||
C._plugin_dir(payload)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", MALFORMED)
|
@pytest.mark.parametrize("payload", MALFORMED)
|
||||||
def test_malformed_ids_are_refused(plugins_dir, payload):
|
def test_malformed_ids_are_refused(plugins_dir, payload):
|
||||||
with pytest.raises(C.ComposerInputError):
|
assert C._plugin_dir(payload) is None
|
||||||
C._plugin_dir(payload)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", ["a", "my-plugin", "x9", "a" * 63])
|
@pytest.mark.parametrize("payload", ["a", "my-plugin", "x9", "a" * 63])
|
||||||
def test_valid_ids_resolve_inside_the_base(plugins_dir, payload):
|
def test_valid_ids_resolve_inside_the_base(plugins_dir, payload):
|
||||||
resolved = C._plugin_dir(payload)
|
resolved = C._plugin_dir(payload)
|
||||||
|
assert resolved is not None, f"{payload!r} was rejected but is valid"
|
||||||
assert resolved.parent == plugins_dir.resolve(), (
|
assert resolved.parent == plugins_dir.resolve(), (
|
||||||
f"{payload!r} resolved to {resolved}, outside {plugins_dir}")
|
f"{payload!r} resolved to {resolved}, outside {plugins_dir}")
|
||||||
|
|
||||||
@@ -71,18 +70,34 @@ def test_no_payload_can_escape_even_if_the_regex_is_loosened(plugins_dir, monkey
|
|||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
||||||
|
import os
|
||||||
escaped = []
|
escaped = []
|
||||||
|
base = os.path.realpath(str(plugins_dir))
|
||||||
for payload in TRAVERSAL:
|
for payload in TRAVERSAL:
|
||||||
try:
|
|
||||||
resolved = C._plugin_dir(payload)
|
resolved = C._plugin_dir(payload)
|
||||||
except C.ComposerInputError:
|
if resolved is None:
|
||||||
continue
|
continue
|
||||||
base = plugins_dir.resolve()
|
real = os.path.realpath(str(resolved))
|
||||||
if resolved != base and base not in resolved.parents:
|
if real != base and os.path.commonpath([base, real]) != base:
|
||||||
escaped.append((payload, str(resolved)))
|
escaped.append((payload, real))
|
||||||
assert not escaped, f"these escaped the base with a loosened regex: {escaped}"
|
assert not escaped, f"these escaped the base with a loosened regex: {escaped}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_sibling_directory_with_a_shared_prefix_is_not_inside(tmp_path, monkeypatch):
|
||||||
|
"""commonpath, not startswith.
|
||||||
|
|
||||||
|
"/x/plugins-evil" starts with "/x/plugins" but is a different directory, so
|
||||||
|
a prefix test would accept it.
|
||||||
|
"""
|
||||||
|
base = tmp_path / "plugins"
|
||||||
|
base.mkdir()
|
||||||
|
(tmp_path / "plugins-evil").mkdir()
|
||||||
|
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
|
||||||
|
import re
|
||||||
|
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
||||||
|
assert C._plugin_dir("../plugins-evil") is None
|
||||||
|
|
||||||
|
|
||||||
def test_a_trailing_newline_is_not_a_valid_id():
|
def test_a_trailing_newline_is_not_a_valid_id():
|
||||||
r"""Python's `$` also matches before a trailing newline, so the original
|
r"""Python's `$` also matches before a trailing newline, so the original
|
||||||
`^...$` accepted "myplugin\n" and would have created a directory whose
|
`^...$` accepted "myplugin\n" and would have created a directory whose
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import ast
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
from typing import Optional
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -598,7 +600,7 @@ class ComposerInputError(ValueError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _plugin_dir(plugin_id: str) -> Path:
|
def _plugin_dir(plugin_id: str) -> Optional[Path]:
|
||||||
"""Resolve a plugin directory, refusing anything outside plugins_dir.
|
"""Resolve a plugin directory, refusing anything outside plugins_dir.
|
||||||
|
|
||||||
_PLUGIN_ID_RE already rejects '/', '.' and '..', so this cannot currently
|
_PLUGIN_ID_RE already rejects '/', '.' and '..', so this cannot currently
|
||||||
@@ -609,15 +611,21 @@ def _plugin_dir(plugin_id: str) -> Path:
|
|||||||
recognises, which is why CodeQL reported sixteen path-injection alerts
|
recognises, which is why CodeQL reported sixteen path-injection alerts
|
||||||
against code that was already safe.
|
against code that was already safe.
|
||||||
|
|
||||||
Raises ComposerInputError if the id is malformed or escapes the base.
|
Returns None for a malformed id or one that escapes the base. It returns
|
||||||
|
rather than raises so the handlers answer with a fixed literal: routing a
|
||||||
|
caught exception's text into a response is what py/stack-trace-exposure
|
||||||
|
flags, and there is nothing here a caller needs beyond "that id is not ok".
|
||||||
"""
|
"""
|
||||||
if not _PLUGIN_ID_RE.match(plugin_id or ''):
|
if not _PLUGIN_ID_RE.match(plugin_id or ''):
|
||||||
raise ComposerInputError('Invalid plugin ID')
|
return None
|
||||||
base = Path(composer_bp.plugins_dir).resolve()
|
base = os.path.realpath(str(composer_bp.plugins_dir))
|
||||||
candidate = (base / plugin_id).resolve()
|
candidate = os.path.realpath(os.path.join(base, plugin_id))
|
||||||
if candidate != base and base not in candidate.parents:
|
# commonpath, not startswith: "/plugins-evil" starts with "/plugins" but is
|
||||||
raise ComposerInputError('Invalid plugin ID')
|
# a different directory. This is also the form static analysis recognises
|
||||||
return candidate
|
# as a containment check.
|
||||||
|
if candidate != base and os.path.commonpath([base, candidate]) != base:
|
||||||
|
return None
|
||||||
|
return Path(candidate)
|
||||||
|
|
||||||
|
|
||||||
def _save_composer_state(target_dir: Path, payload: dict) -> None:
|
def _save_composer_state(target_dir: Path, payload: dict) -> None:
|
||||||
@@ -697,10 +705,9 @@ def install_locally():
|
|||||||
# different function -- re-check here, at the point the path is actually
|
# different function -- re-check here, at the point the path is actually
|
||||||
# built, so this route stays safe on its own if that call is ever
|
# built, so this route stays safe on its own if that call is ever
|
||||||
# reordered or changed.
|
# reordered or changed.
|
||||||
try:
|
|
||||||
target = _plugin_dir(plugin_id)
|
target = _plugin_dir(plugin_id)
|
||||||
except ComposerInputError as exc:
|
if target is None:
|
||||||
return jsonify({'status': 'error', 'message': str(exc)}), 400
|
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
|
||||||
force = bool(data.get('_force', False))
|
force = bool(data.get('_force', False))
|
||||||
|
|
||||||
if target.exists() and not force:
|
if target.exists() and not force:
|
||||||
@@ -754,10 +761,10 @@ def validate_id(plugin_id):
|
|||||||
if not _PLUGIN_ID_RE.match(plugin_id):
|
if not _PLUGIN_ID_RE.match(plugin_id):
|
||||||
return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'})
|
return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'})
|
||||||
if composer_bp.plugins_dir:
|
if composer_bp.plugins_dir:
|
||||||
try:
|
resolved = _plugin_dir(plugin_id)
|
||||||
taken = _plugin_dir(plugin_id).exists()
|
if resolved is None:
|
||||||
except ComposerInputError:
|
|
||||||
return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'})
|
return jsonify({'valid': False, 'available': False, 'reason': 'Invalid format'})
|
||||||
|
taken = resolved.exists()
|
||||||
if taken:
|
if taken:
|
||||||
return jsonify({'valid': True, 'available': False, 'reason': 'Already installed'})
|
return jsonify({'valid': True, 'available': False, 'reason': 'Already installed'})
|
||||||
return jsonify({'valid': True, 'available': True})
|
return jsonify({'valid': True, 'available': True})
|
||||||
@@ -826,10 +833,9 @@ def load_plugin(plugin_id):
|
|||||||
"""
|
"""
|
||||||
if not composer_bp.plugins_dir:
|
if not composer_bp.plugins_dir:
|
||||||
return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503
|
return jsonify({'status': 'error', 'message': 'Plugin directory not configured'}), 503
|
||||||
try:
|
|
||||||
plugin_dir = _plugin_dir(plugin_id)
|
plugin_dir = _plugin_dir(plugin_id)
|
||||||
except ComposerInputError as exc:
|
if plugin_dir is None:
|
||||||
return jsonify({'status': 'error', 'message': str(exc)}), 400
|
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
|
||||||
if not plugin_dir.exists():
|
if not plugin_dir.exists():
|
||||||
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
|
||||||
|
|
||||||
|
|||||||
@@ -421,6 +421,9 @@ a, button, input, select, textarea {
|
|||||||
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
.md\:flex { display: flex; }
|
.md\:flex { display: flex; }
|
||||||
.md\:hidden { display: none; }
|
.md\:hidden { display: none; }
|
||||||
|
/* composer.html labels its toolbar buttons `hidden md:inline`, so without
|
||||||
|
this the label is hidden at every width and the buttons stay icon-only. */
|
||||||
|
.md\:inline { display: inline; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user