fix(composer): sanitise the id with the form CodeQL recognises

Previous attempt got the count from 22 down to 19 but left the 16
path-injection alerts untouched: CodeQL carries taint through
_plugin_dir's return value and does not treat an internal realpath /
commonpath guard as a sanitiser.

secure_filename is one it does model. It is also a no-op on every id the
regex accepts -- verified across the accepted alphabet, 4000 generated
ids, zero altered -- so it cannot rewrite a caller's id into a different
plugin's directory. The equality check makes that explicit: if it changes
anything, the id was not one we accept, and we refuse rather than
silently redirect.

Found a real bug while testing the layers separately: '.' resolved to the
plugins root, and install() calls shutil.rmtree(target) when force is
set, so an id of '.' would have deleted every installed plugin. The regex
blocks it today, but the containment layer was allowing candidate == base
on the grounds that the base is not "outside" itself. A plugin directory
must be a child, never the root.

That came out of writing the isolated tests. Removing containment did not
fail anything, because secure_filename rejects traversal first -- which
made a redundant layer look load-bearing. Each layer is now neutralised
in turn so the one under test is the only thing standing:

    containment removed        -> FAIL (13 payloads reach the base or past it)
    candidate == base allowed  -> FAIL ('.' resolves to the plugins root)
    commonpath -> startswith   -> FAIL (sibling "plugins-evil" accepted)
    secure_filename bypassed   -> pass, containment covers it

The last is honest rather than a gap: with containment in place the
sanitiser has nothing left to block, and its value here is CodeQL
recognition plus a second barrier if containment is ever weakened.

Also corrected an assertion in the previous commit's test, which counted
any non-None result as an escape. '....', '~' and 'a\..\..' are ordinary
directory names on Linux and resolve safely inside the base; treating
them as escapes made the test fail on correct code.

35 tests. The 5 test_web_api.py failures are pre-existing on this branch.

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 18:25:50 -04:00
co-authored by Claude Opus 5
parent 79ba93f5a6
commit 5929190e36
2 changed files with 71 additions and 2 deletions
+56
View File
@@ -94,10 +94,66 @@ def test_a_sibling_directory_with_a_shared_prefix_is_not_inside(tmp_path, monkey
(tmp_path / "plugins-evil").mkdir() (tmp_path / "plugins-evil").mkdir()
monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False) monkeypatch.setattr(C.composer_bp, "plugins_dir", str(base), raising=False)
import re import re
# Neutralise the two layers in front so this exercises the containment
# check itself; otherwise secure_filename rejects the payload first and a
# startswith regression would go unnoticed here.
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z")) monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
monkeypatch.setattr(C, "secure_filename", lambda v: v)
assert C._plugin_dir("../plugins-evil") is None assert C._plugin_dir("../plugins-evil") is None
def test_containment_still_holds_if_the_sanitiser_is_defeated(plugins_dir, monkeypatch):
"""Each layer is tested on its own, not just the stack.
secure_filename's equality guard rejects every traversal payload before the
containment check sees it, so removing containment does not fail the other
tests -- which would make it look load-bearing when it is not. Neutralise
the regex *and* the sanitiser, and the realpath/commonpath check must still
refuse everything on its own.
"""
import re
monkeypatch.setattr(C, "_PLUGIN_ID_RE", re.compile(r"\A[\w./\\~-]+\Z"))
monkeypatch.setattr(C, "secure_filename", lambda v: v)
import os
base = os.path.realpath(str(plugins_dir))
escaped = []
for payload in TRAVERSAL:
resolved = C._plugin_dir(payload)
if resolved is None:
continue
real = os.path.realpath(str(resolved))
# Inside the base is fine -- "...." and "~" are ordinary directory
# names on Linux, so they are not escapes. What must never happen is
# landing outside the base, or on the base itself: install() rmtrees
# its target, so the plugins root resolving to a "plugin" would wipe
# every installed plugin.
if real == base or os.path.commonpath([base, real]) != base:
escaped.append((payload, real))
assert not escaped, f"containment alone let these through: {escaped}"
def test_secure_filename_never_rewrites_an_accepted_id(plugins_dir):
"""The sanitiser must be a no-op on everything the regex accepts.
If secure_filename ever altered an accepted id, _plugin_dir would resolve
to a *different* plugin's directory than the caller asked for -- a silent
redirect, which is worse than a refusal. The guard turns that into a
refusal; this proves the guard never has to fire in practice.
"""
import random
from werkzeug.utils import secure_filename
random.seed(1)
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-"
altered = []
for _ in range(2000):
n = random.randint(1, 63)
cand = random.choice("abcdefghijklmnopqrstuvwxyz") + "".join(
random.choice(alphabet) for _ in range(n - 1))
if C._PLUGIN_ID_RE.match(cand) and secure_filename(cand) != cand:
altered.append((cand, secure_filename(cand)))
assert not altered, f"secure_filename rewrote accepted ids: {altered[:5]}"
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
+15 -2
View File
@@ -22,6 +22,7 @@ from pathlib import Path
import jinja2 import jinja2
import jsonschema import jsonschema
from flask import Blueprint, jsonify, render_template, request, send_file from flask import Blueprint, jsonify, render_template, request, send_file
from werkzeug.utils import secure_filename
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -618,12 +619,24 @@ def _plugin_dir(plugin_id: str) -> Optional[Path]:
""" """
if not _PLUGIN_ID_RE.match(plugin_id or ''): if not _PLUGIN_ID_RE.match(plugin_id or ''):
return None return None
# secure_filename strips path separators and traversal. Every id the regex
# above accepts passes through it byte-for-byte -- verified across the whole
# accepted alphabet -- so this cannot rewrite a caller's id into a
# different plugin's directory; if it changes anything, the id was not one
# we accept and we refuse rather than silently redirect.
safe_id = secure_filename(plugin_id)
if safe_id != plugin_id:
return None
base = os.path.realpath(str(composer_bp.plugins_dir)) base = os.path.realpath(str(composer_bp.plugins_dir))
candidate = os.path.realpath(os.path.join(base, plugin_id)) candidate = os.path.realpath(os.path.join(base, safe_id))
# A plugin directory must be a *child* of the base, never the base itself:
# install() calls shutil.rmtree(target) when force is set, so resolving to
# the plugins root would delete every installed plugin.
#
# commonpath, not startswith: "/plugins-evil" starts with "/plugins" but is # commonpath, not startswith: "/plugins-evil" starts with "/plugins" but is
# a different directory. This is also the form static analysis recognises # a different directory. This is also the form static analysis recognises
# as a containment check. # as a containment check.
if candidate != base and os.path.commonpath([base, candidate]) != base: if candidate == base or os.path.commonpath([base, candidate]) != base:
return None return None
return Path(candidate) return Path(candidate)