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:
ChuckBuilds
2026-08-21 17:52:11 -04:00
co-authored by Claude Opus 5
parent e499efb1f0
commit 79ba93f5a6
3 changed files with 53 additions and 29 deletions
+24 -9
View File
@@ -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
+23 -17
View File
@@ -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
+3
View File
@@ -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) {