mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-22 02:48:15 +00:00
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:
co-authored by
Claude Opus 5
parent
79ba93f5a6
commit
5929190e36
@@ -22,6 +22,7 @@ from pathlib import Path
|
||||
import jinja2
|
||||
import jsonschema
|
||||
from flask import Blueprint, jsonify, render_template, request, send_file
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
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 ''):
|
||||
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))
|
||||
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
|
||||
# a different directory. This is also the form static analysis recognises
|
||||
# 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 Path(candidate)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user