mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-22 02:48:15 +00:00
CodeQL reports 19 alerts against this PR -- 16 high-severity
py/path-injection plus 3 py/stack-trace-exposure -- all in
web_interface/blueprints/composer.py, where a request-supplied plugin_id
reaches Path(plugins_dir) / plugin_id and the result is created, written
to, deleted with shutil.rmtree, and read back.
The path-injection alerts are false positives today. _PLUGIN_ID_RE is
fully anchored and permits only [a-z][a-z0-9-]{0,62}, so every traversal
payload is already rejected; I checked fourteen of them, including
../../etc/passwd, a/../../etc, /etc/passwd and encoded variants, and none
gets past it.
They are worth fixing anyway. The guarantee lived in a regex several
hundred lines from the path building, so relaxing that pattern later --
to allow an underscore, say -- would open a traversal with nothing at the
filesystem boundary to catch it. _plugin_dir() now resolves the candidate
and refuses anything that is not inside plugins_dir, and all three call
sites go through it. That is also the shape static analysis recognises,
which is why sixteen alerts landed on code that was already safe.
The regex anchor moves from $ to \Z. Python's $ also matches just before
a trailing newline, so "myplugin\n" was accepted and would have created a
directory whose name ends in one. Not traversal, but not a name anything
downstream should have to handle.
For the stack-trace exposure: the handlers returned str(exc) for any
ValueError out of _generate_plugin_files. The seven raises there are all
curated, user-facing validation messages, and they now use a
ComposerInputError subclass so they keep reaching the user verbatim. A
ValueError from anywhere else -- json, int(), a library -- is logged with
a traceback and answered generically, since its text can name internal
paths.
Verified: 32 tests covering fourteen traversal payloads and twelve
malformed ids. The key one re-runs every payload with the id pattern
deliberately loosened to allow slashes and dots; removing the containment
check fails it with
these escaped the base with a loosened regex:
[('/etc/passwd', '/etc/passwd'), ('//etc/passwd', '//etc/passwd')]
so the boundary is doing real work rather than shadowing the regex.
The 5 failures in test_web_api.py are unrelated and 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
92 lines
3.5 KiB
Python
92 lines
3.5 KiB
Python
"""A composer plugin id must never resolve outside the plugins directory.
|
|
|
|
CodeQL reported sixteen high-severity py/path-injection alerts against
|
|
web_interface/blueprints/composer.py: a request-supplied plugin_id reaching
|
|
Path(plugins_dir) / plugin_id, which is then created, written to, deleted
|
|
(shutil.rmtree) and read back.
|
|
|
|
The id was already validated by an anchored regex, so every traversal payload
|
|
was in fact rejected. What was missing was the guarantee living *with* the path
|
|
building rather than in a regex several hundred lines away -- loosen that regex
|
|
later and the traversal opens silently, with nothing at the filesystem boundary
|
|
to catch it. _plugin_dir() closes that, and is the form static analysis can see.
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from web_interface.blueprints import composer as C # noqa: E402
|
|
|
|
#: Anything that has ever been used to climb out of a directory.
|
|
TRAVERSAL = [
|
|
"../../etc/passwd", "..", ".", "a/../../etc", "good/../../..",
|
|
"/etc/passwd", "//etc/passwd", "a\\..\\..", "a%2f..%2f..",
|
|
"....//....//etc", "a/./../../etc", "~", "~root",
|
|
"plugin/../../../../../../etc/shadow",
|
|
]
|
|
|
|
#: Rejected for shape, not traversal -- but rejected all the same.
|
|
MALFORMED = ["", "A-upper", "1-leading-digit", "-leading-dash", "has_underscore",
|
|
"has space", "has.dot", "a" * 64, "plugin\n", "plugin\n../../etc",
|
|
"\n", "plug\x00in"]
|
|
|
|
|
|
@pytest.fixture
|
|
def plugins_dir(tmp_path, monkeypatch):
|
|
base = tmp_path / "plugin-repos"
|
|
base.mkdir()
|
|
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
|
|
return base
|
|
|
|
|
|
@pytest.mark.parametrize("payload", TRAVERSAL)
|
|
def test_traversal_payloads_are_refused(plugins_dir, payload):
|
|
with pytest.raises(C.ComposerInputError):
|
|
C._plugin_dir(payload)
|
|
|
|
|
|
@pytest.mark.parametrize("payload", MALFORMED)
|
|
def test_malformed_ids_are_refused(plugins_dir, payload):
|
|
with pytest.raises(C.ComposerInputError):
|
|
C._plugin_dir(payload)
|
|
|
|
|
|
@pytest.mark.parametrize("payload", ["a", "my-plugin", "x9", "a" * 63])
|
|
def test_valid_ids_resolve_inside_the_base(plugins_dir, payload):
|
|
resolved = C._plugin_dir(payload)
|
|
assert resolved.parent == plugins_dir.resolve(), (
|
|
f"{payload!r} resolved to {resolved}, outside {plugins_dir}")
|
|
|
|
|
|
def test_no_payload_can_escape_even_if_the_regex_is_loosened(plugins_dir, monkeypatch):
|
|
"""The containment check must stand on its own.
|
|
|
|
This is the whole point of resolving at the filesystem boundary: if the id
|
|
pattern is ever relaxed, traversal must still be impossible. Replace the
|
|
regex with one that permits slashes and dots, then re-run the payloads.
|
|
"""
|
|
import re
|
|
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
|
|
escaped = []
|
|
for payload in TRAVERSAL:
|
|
try:
|
|
resolved = C._plugin_dir(payload)
|
|
except C.ComposerInputError:
|
|
continue
|
|
base = plugins_dir.resolve()
|
|
if resolved != base and base not in resolved.parents:
|
|
escaped.append((payload, str(resolved)))
|
|
assert not escaped, f"these escaped the base with a loosened regex: {escaped}"
|
|
|
|
|
|
def test_a_trailing_newline_is_not_a_valid_id():
|
|
r"""Python's `$` also matches before a trailing newline, so the original
|
|
`^...$` accepted "myplugin\n" and would have created a directory whose
|
|
name ends in one. \Z does not."""
|
|
assert C._PLUGIN_ID_RE.match("myplugin") is not None
|
|
assert C._PLUGIN_ID_RE.match("myplugin\n") is None
|