Serve an uploaded file as data, not as a document that can run

An SVG is an XML document that may carry a script, and it is an accepted image
type because floor-plan maps and branding genuinely want vector. Loaded through
an img tag that script never runs, so the tiles and maps were never the risk.
Opening the file's own URL is - and the application image route is public, so
that URL needs no session.

Every route that serves an upload now goes through one helper that sends
Content-Security-Policy: default-src 'none'; sandbox, and nosniff. Seven routes
across core and five plugins, so a new one added later starts from the same
place rather than repeating the reasoning. Banning the format instead would
have cost the maps their only sensible one.

The app also sent no security headers at all. It now sets nosniff,
frame-ancestors self (as X-Frame-Options too, for the display bays' browsers)
and a referrer policy. Deliberately NOT a page-wide CSP: this serves an SPA with
inline styles, so a real script-src policy is a change worth making with the
frontend in front of you, and a permissive header claiming one would be worse
than having none.

Contract 0.19.0. send_upload is on the shopdb.api surface, because a plugin
serving user-supplied bytes should not have to remember these headers. The same
bump records that get_dashboard_widgets has taken data and shape rather than a
component name since the dashboard was rebuilt - that shipped without a bump,
while BasePlugin and PLUGIN-HOOKS.md both still documented the shape nothing
renders, which is how five plugins came to declare widgets pointing at
components nobody had written.
This commit is contained in:
cproudlock
2026-08-14 13:46:53 -04:00
parent d830dd49a9
commit c7dffce81e
12 changed files with 229 additions and 31 deletions

View File

@@ -3,7 +3,7 @@
import glob
import os
from flask import Blueprint, request, current_app, send_from_directory
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
@@ -19,6 +19,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.uploads import send_upload
models_bp = Blueprint('models', __name__)
@@ -228,7 +229,7 @@ def upload_model_image(model_id: int):
@models_bp.route('/image/<path:filename>', methods=['GET'])
def serve_model_image(filename):
"""Serve an uploaded model image (public - asset detail pages read it)."""
return send_from_directory(_modelimage_dir(), filename)
return send_upload(_modelimage_dir(), filename)
@models_bp.route('/<int:model_id>/image', methods=['DELETE'])