security: triage the CodeQL backlog — 129 alerts, three of them live (#561)

* fix(web): escape quotes in every HTML escaper, not just & < >

The escapers are all `div.textContent = x; return div.innerHTML`. That
round-trip escapes &, < and > -- the only characters the HTML serializer
must escape in a text node -- and leaves quotes alone. Every widget then
interpolates the result into a quoted attribute value:

    value="${escapeHtml(v)}"   title="${escapeHtml(v)}"

so a value of `x" onmouseover="alert(1)` closes the attribute and adds an
event handler of its own. CodeQL reported this 83 times
(js/incomplete-html-attribute-sanitization) across the widget files.

It is one bug, not 83: the widgets each carry a standalone fallback that
did escape quotes, but they all prefer BaseWidget.escapeHtml when
window.BaseWidget exists -- which it always does in the shipped page -- so
the correct fallbacks were dead code and the incomplete shared one ran.
Fixed at each source instead of at the call sites.

app-shell.js already documented this exact gap in a comment and worked
around it by building DOM nodes by hand; that workaround stays (setting a
property cannot be got wrong), the comment is now accurate.

cache.html's delete button interpolated the cache key into
`onclick="deleteCacheFile('...')"`. Escaping cannot help there -- the
browser HTML-decodes the attribute before parsing it as JS, so `&#39;`
becomes a real `'` again -- so the key moves to a data-cache-key
attribute that the handler reads back.

url-input.js additionally wrote a value straight into an <a href> after
validating it against a schema-supplied protocol list, and that list
accepted any RFC 3986 scheme -- "javascript" included. Scriptable schemes
(javascript, data, vbscript, blob, filesystem) are now refused both when
the list is normalised and when a URL is checked against it, and the
render path routes its href through the same check instead of emitting
whatever was stored (js/xss-through-dom).

test/js/unit/test_html_escaping.js reads each escaper out of the shipped
file and runs it, so losing the quote handling again fails a test rather
than a scan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): stop request-supplied names from reaching paths outside their base

Three of the py/path-injection alerts were live, not lint:

* GET /api/v3/plugins/<plugin_id>/static/<path:file_path> read any file
  whose resolved path *string-prefixed* the plugin directory. Flask's
  default converter forbids a slash but not dots, and
  get_plugin_directory('..') returned the parent of the plugins directory
  because it exists -- so every file under the project root then prefixed
  that directory, config/config_secrets.json included. The prefix check
  was also wrong on its own terms: with plugin dir "plugin-repos/foo",
  "../foo-evil/x" resolves to "plugin-repos/foo-evil/x", whose string does
  start with "plugin-repos/foo".

* POST /api/v3/plugins/of-the-day/json/delete interpolated the request
  body's file_id into f"{file_id}.json" and unlinked it, unvalidated. A
  file_id of "../../../../etc/something" deleted that file. This is the
  one finding in the batch that destroyed data rather than exposing it.

* POST /api/v3/cache/delete passed the body's key through
  CacheManager.clear_cache to DiskCache, which joined it as a filename and
  called os.remove. Same shape, same result. The guard goes in
  DiskCache.get_cache_path, the single choke point get/set/clear share, so
  every caller is covered rather than just this route. Real keys are the
  stems of files already flat in the cache directory -- that is how
  list_cache_files derives them -- so nothing legitimate is turned away.

The rest of the cluster (web_interface/app.py's asset route, the plugin
update handler, _get_plugin_version, the plugin-schema read in config.py)
was guarded in ways that held, but each had grown its own version of the
check. They now go through one helper, src/common/path_safety.py, which
returns the *sanitised value* rather than a verdict -- so a caller cannot
validate one string and open another, which is how the two real bugs
above were shaped.

Also: WiFiManager.connect_to_network took the SSID and password straight
from POST /api/v3/wifi/connect into nmcli's argv. There is no shell there,
so CodeQL's py/command-line-injection alert overstates the risk -- but
nmcli reads a leading "-" as an option, so an SSID of "--ask" asks nmcli
to run differently rather than to join a network. Both values are now
checked for shape (802.11's 32-octet SSID limit, WPA's 8-63 char
passphrase or 64-char hex key, no control characters, no leading dash)
before any subprocess runs.

test/test_path_traversal_guards.py asserts on the filesystem, not just
the status code: a handler that returns 403 and deletes the file anyway
would pass the weaker check. Twelve of its cases fail against the
unpatched code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): refuse a plugin id that is not a plain name, don't truncate it

pages_v3 and scripts/dev_server.py ran request ids through
os.path.basename and carried on with what came out, so "../weather"
rendered the config form for "weather". Nothing escaped the plugins
directory -- the relative_to guards held -- but the handler answered a
request nobody made, and validating one string while the filesystem sees
another is the shape both live traversals earlier in this branch had.

Same treatment as the rest: safe_path_component rejects rather than
truncates, resolve_under returns the path it checked, and the call sites
use what those return. The three handlers that had hand-rolled
resolve-and-relative_to blocks lose about twenty lines to the shared one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(web): say what the plugin web_ui iframe actually is

The docstring claimed the fragment runs "in a sandboxed iframe". The
iframe in plugin_config.html carries no sandbox attribute, so the
fragment runs with the interface's own origin. That is fine -- the file
belongs to an installed plugin, and an installed plugin already runs
Python on the device, so the trust boundary is install rather than this
route -- but a comment promising containment that is not there is worse
than no comment. This is the context for the py/reflective-xss alert on
this handler.

Also drops the now-unused os/os.path imports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): inline url-input's scheme guard at the previewLink.href sink

CodeQL flagged this line as a new high-severity js/xss-through-dom alert
on this PR even though it is already covered by SCRIPTABLE_SCHEMES: the
guard reached the sink through safeHref -> isValidUrl, two function calls
away, which its DOM-based-XSS sanitizer recognition does not trace.

Behavior is unchanged -- same scheme check, same SCRIPTABLE_SCHEMES list,
same allowedProtocols gate -- just inlined directly above the
previewLink.href assignment it guards, so the barrier is visible in the
same scope as the sink.

Added a regression test that runs the shipped onInput handler (not just
the extracted helpers) against a mocked DOM, so a future change that
reintroduces an unguarded previewLink.href assignment fails here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): address CodeRabbit findings on the CodeQL triage PR

- src/wifi_manager.py: reject non-ASCII WPA-PSK passphrases before any
  credential-saving or connect flow runs. NetworkManager only accepts
  printable ASCII passphrases (or a 64-char hex key); a non-ASCII value
  was previously saved/attempted before nmcli itself rejected it.

- web_interface/blueprints/api_v3/config.py: fail closed when the
  plugin config schema path can't be resolved under the plugins
  directory (e.g. a symlinked plugin dir). Previously this fell
  through with secret_fields left empty, so submitted credentials for
  that plugin were saved as ordinary, unencrypted configuration.

- web_interface/static/v3/js/widgets/plugin-file-manager.js: stop
  splicing the JSON day/column key into an inline oninput="..." handler
  string. escHtml() escapes quotes for a normal HTML attribute, but the
  browser HTML-decodes the attribute before running it as script, which
  undoes that escaping and lets a crafted column name (e.g. from an
  uploaded JSON file) break out of the JS string and execute. Cell
  edits now travel through data-day/data-col attributes read by one
  delegated 'input' listener instead.

  While in this file: fixed 6 pre-existing missing-')' typos on
  multi-line safeSetHTML(...) calls (already flagged by Biome in this
  PR's own CodeRabbit run as syntax errors blocking its lint pass).
  These predate this PR (present on main too) but made the whole file
  fail to parse in any JS engine, which is a bigger problem than the
  XSS finding itself and directly touches the same lines.

Added/extended regression tests for each fix; full suites pass
(pytest: 4580 passed, 62 skipped; JS: 84 assertions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-11 15:32:03 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 5137e86d16
commit f6367d63ae
27 changed files with 1520 additions and 170 deletions
+75 -22
View File
@@ -18,6 +18,9 @@ from web_interface.blueprints.api_v3 import (
separate_secrets, shutil, stat, subprocess, success_response, sys,
tempfile, uuid, validate_request_json,
)
from src.common.path_safety import (
resolve_under, safe_path_component, safe_relative_parts,
)
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
@@ -946,12 +949,29 @@ def update_plugin():
status_code=500
)
plugin_id = data['plugin_id']
# The id names a directory this handler reads a manifest out of and
# hands to the store manager to run git in. It comes from the request
# body, so it is validated before anything is joined to a path, and
# the validated value -- not the raw one -- is what gets used below.
plugin_id = safe_path_component(data['plugin_id'])
if not plugin_id:
return error_response(
ErrorCode.INVALID_INPUT,
'Invalid plugin_id',
status_code=400
)
# Always do direct updates (they're fast git pull operations)
# Operation queue is reserved for longer operations like install/uninstall
plugin_dir = Path(api_v3.plugin_store_manager.plugins_dir) / plugin_id
manifest_path = plugin_dir / "manifest.json"
plugins_base = Path(api_v3.plugin_store_manager.plugins_dir)
plugin_dir = plugins_base / plugin_id
manifest_path = resolve_under(plugin_dir, "manifest.json")
if manifest_path is None:
return error_response(
ErrorCode.INVALID_INPUT,
'Invalid plugin_id',
status_code=400
)
current_last_updated = None
current_commit = None
@@ -983,7 +1003,9 @@ def update_plugin():
current_branch = git_info_before.get('branch')
# Check if plugin is a git repo first (for better error messages)
plugin_path_dir = Path(api_v3.plugin_store_manager.plugins_dir) / plugin_id
# plugin_id is validated above; reuse the same directory rather
# than rebuilding it from a value that might not match.
plugin_path_dir = plugin_dir
is_git_repo = False
if plugin_path_dir.exists():
git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_path_dir)
@@ -1069,7 +1091,7 @@ def update_plugin():
message=message
)
else:
plugin_path_dir = Path(api_v3.plugin_store_manager.plugins_dir) / plugin_id
plugin_path_dir = plugin_dir
if not plugin_path_dir.exists():
client_msg = 'Plugin update failed: plugin not found'
else:
@@ -3525,6 +3547,14 @@ def delete_of_the_day_json():
if not file_id:
return jsonify({'status': 'error', 'message': 'file_id is required'}), 400
# file_id names a file that is about to be unlinked, and it arrives
# straight from the request body. Unvalidated, a file_id of
# "../../../../etc/cron" made this endpoint delete any .json file on
# the device the service could write to.
safe_file_id = safe_path_component(file_id)
if not safe_file_id:
return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400
# Get plugin directory
plugin_id = 'ledmatrix-of-the-day'
if api_v3.plugin_manager:
@@ -3535,9 +3565,10 @@ def delete_of_the_day_json():
if not plugin_dir or not Path(plugin_dir).exists():
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
data_dir = Path(plugin_dir) / 'of_the_day'
filename = f"{file_id}.json"
file_path = data_dir / filename
filename = f"{safe_file_id}.json"
file_path = resolve_under(Path(plugin_dir) / 'of_the_day', filename)
if file_path is None:
return jsonify({'status': 'error', 'message': 'Invalid file_id'}), 400
if not file_path.exists():
return jsonify({'status': 'error', 'message': f'File {filename} not found'}), 404
@@ -3549,7 +3580,7 @@ def delete_of_the_day_json():
try:
sys.path.insert(0, str(plugin_dir))
from scripts.update_config import remove_category_from_config
remove_category_from_config(file_id)
remove_category_from_config(safe_file_id)
except Exception as e:
logger.warning("Could not update config: %s", e)
@@ -3563,23 +3594,44 @@ def delete_of_the_day_json():
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
@api_v3.route('/plugins/<plugin_id>/static/<path:file_path>', methods=['GET'])
def serve_plugin_static(plugin_id, file_path):
"""Serve static files from plugin directory"""
"""Serve static files from plugin directory.
Both URL parts are validated before anything is opened. This handler used
to read whatever the path resolved to as long as ``str(file).startswith``
the plugin directory, which let two different things through:
* ``plugin_id`` of ``..`` -- Flask's default converter forbids a slash but
not dots, and ``get_plugin_directory('..')`` happily returned the parent
of the plugins directory because it exists. Every file under the project
root then "started with" that directory, ``config/config_secrets.json``
included.
* a sibling directory sharing a prefix: with the plugin directory
``plugin-repos/foo``, ``../foo-evil/x`` resolves to
``plugin-repos/foo-evil/x``, whose string does start with
``plugin-repos/foo``.
"""
try:
safe_plugin_id = safe_path_component(plugin_id)
if not safe_plugin_id:
return jsonify({'status': 'error', 'message': 'Invalid plugin ID'}), 400
safe_parts = safe_relative_parts(file_path)
if not safe_parts:
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 400
# Get plugin directory
if api_v3.plugin_manager:
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
plugin_dir = api_v3.plugin_manager.get_plugin_directory(safe_plugin_id)
else:
plugin_dir = PROJECT_ROOT / 'plugins' / plugin_id
plugin_dir = PROJECT_ROOT / 'plugins' / safe_plugin_id
if not plugin_dir or not Path(plugin_dir).exists():
return jsonify({'status': 'error', 'message': 'Plugin not found'}), 404
# Resolve file path (prevent directory traversal)
plugin_dir = Path(plugin_dir).resolve()
requested_file = (plugin_dir / file_path).resolve()
# Security check: ensure file is within plugin directory
if not str(requested_file).startswith(str(plugin_dir)):
# Containment is still checked after resolving: name validation cannot
# see a symlink inside the plugin directory that points out of it.
requested_file = resolve_under(plugin_dir, *safe_parts)
if requested_file is None:
return jsonify({'status': 'error', 'message': 'Invalid file path'}), 403
# Check if file exists
@@ -3588,13 +3640,14 @@ def serve_plugin_static(plugin_id, file_path):
# Determine content type
content_type = 'text/plain'
if file_path.endswith('.html'):
name = requested_file.name
if name.endswith('.html'):
content_type = 'text/html'
elif file_path.endswith('.js'):
elif name.endswith('.js'):
content_type = 'application/javascript'
elif file_path.endswith('.css'):
elif name.endswith('.css'):
content_type = 'text/css'
elif file_path.endswith('.json'):
elif name.endswith('.json'):
content_type = 'application/json'
# Read and return file