fix(composer): build the font path from the allowlist entry

secure_filename cleared the plugin-directory alerts: CodeQL went from 19
to 5, and from 16 high-severity to 2. The two that remain are in
serve_font, which is gated by a frozenset of three exact filenames -- so
nothing was exploitable -- but the name reaching the filesystem was still
the request value.

It now comes from the matched allowlist entry. Identical strings, so
runtime behaviour is unchanged; the difference is that the filename is
provably a module constant rather than a guarded piece of user input.

The first test I wrote for this proved nothing. It asserted 404 on
traversal payloads, but Flask's router will not match a path segment
containing '/', and the rest 404 simply because no such file exists --
so removing the allowlist entirely still passed. Replaced with a readable
file planted next to the fonts:

    fonts/id_rsa.ttf  ->  404, body does not contain its contents

which fails with "a readable non-allowlisted file was served" the moment
the gate is removed.

45 tests.

Left alone: three medium py/stack-trace-exposure alerts on the
_generate_plugin_files handlers, which return str(exc) for
ComposerInputError. Its seven raise sites are all authored literals
("Author is required.", "Config variable key X is not a valid Python
identifier."), so no traceback or path is exposed. Clearing them means
either replacing that feedback with a generic string or restructuring
validation to return errors instead of raising -- a change to the
author's design, made blind, since CodeQL cannot be run locally to
confirm it would even work. That is a decision, not a cleanup.

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:56:00 -04:00
co-authored by Claude Opus 5
parent 5929190e36
commit 5133643600
2 changed files with 71 additions and 2 deletions
+8 -2
View File
@@ -758,11 +758,17 @@ def install_locally():
@composer_bp.route('/api/fonts/<font_name>')
def serve_font(font_name):
"""Serve an allowlisted font file for canvas FontFace loading."""
if font_name not in _ALLOWED_FONTS:
# Build the path from the allowlist entry, not from the request value.
# They are equal strings, so this changes nothing at runtime -- but the
# name that reaches the filesystem now provably originates in a module
# constant, which is the difference between "guarded" and "not derived
# from user input at all".
allowed_name = next((f for f in sorted(_ALLOWED_FONTS) if f == font_name), None)
if allowed_name is None:
return '', 404
if not composer_bp.project_root:
return '', 503
font_path = Path(composer_bp.project_root) / 'assets' / 'fonts' / font_name
font_path = Path(composer_bp.project_root) / 'assets' / 'fonts' / allowed_name
if not font_path.exists():
return '', 404
return send_file(str(font_path), mimetype='font/ttf')