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:
@@ -13,7 +13,7 @@ import io
|
||||
import logging
|
||||
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
|
||||
|
||||
@@ -26,6 +26,7 @@ from shopdb.api import (
|
||||
require_role,
|
||||
)
|
||||
from shopdb.api import Setting
|
||||
from shopdb.api import send_upload
|
||||
|
||||
from ..models import DirectoryEmployee
|
||||
|
||||
@@ -612,7 +613,7 @@ def upload_employee_photo(sso):
|
||||
@employees_bp.route('/photo/<path:filename>', methods=['GET'])
|
||||
def serve_employee_photo(filename):
|
||||
"""Serve an uploaded employee photo (public - kiosk cards read it)."""
|
||||
return send_from_directory(_employeephoto_dir(), filename)
|
||||
return send_upload(_employeephoto_dir(), filename)
|
||||
|
||||
|
||||
@employees_bp.route('/<int:sso>/photo', methods=['DELETE'])
|
||||
|
||||
@@ -236,8 +236,7 @@ def upload_item_image(item_id: int):
|
||||
@printedparts_bp.route('/image/<path:filename>', methods=['GET'])
|
||||
def serve_item_image(filename):
|
||||
"""Serve an uploaded item image (public - kiosk and list read it)."""
|
||||
from flask import send_from_directory
|
||||
return send_from_directory(_imagedir(), filename)
|
||||
return send_upload(_imagedir(), filename)
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
||||
@@ -643,6 +642,7 @@ def report_by_person():
|
||||
# --- print files: append-only revisions per item ------------------------------
|
||||
|
||||
from flask_jwt_extended import get_jwt_identity
|
||||
from shopdb.api import send_upload
|
||||
|
||||
from ..models import PrintedItemFile
|
||||
|
||||
@@ -737,12 +737,11 @@ def upload_item_file(item_id: int):
|
||||
@jwt_required(optional=True)
|
||||
def download_item_file(file_id: int):
|
||||
"""Download a revision under its original filename."""
|
||||
from flask import send_from_directory
|
||||
record = db.session.get(PrintedItemFile, file_id)
|
||||
if not record:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'File not found',
|
||||
http_code=404)
|
||||
return send_from_directory(_filedir(), record.storedfilename,
|
||||
return send_upload(_filedir(), record.storedfilename,
|
||||
as_attachment=True,
|
||||
download_name=record.filename)
|
||||
|
||||
|
||||
@@ -9,12 +9,13 @@ have it by default).
|
||||
import os
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, current_app, jsonify, send_from_directory
|
||||
from flask import Blueprint, request, current_app, jsonify
|
||||
from flask_jwt_extended import jwt_required
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.api import (db, success_response, error_response, ErrorCodes,
|
||||
require_permission)
|
||||
from shopdb.api import send_upload
|
||||
|
||||
from ..models import TvSlide
|
||||
|
||||
@@ -81,7 +82,7 @@ def serve_image(surface, filename):
|
||||
directory = _surface_dir(surface)
|
||||
if not os.path.isfile(os.path.join(directory, safe)):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Slide not found', http_code=404)
|
||||
return send_from_directory(directory, safe)
|
||||
return send_upload(directory, safe)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -9,11 +9,12 @@ import glob
|
||||
import os
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
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 sqlalchemy.orm import joinedload
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.api import send_upload
|
||||
from shopdb.api import (
|
||||
db, Asset,
|
||||
success_response, error_response, ErrorCodes,
|
||||
@@ -559,7 +560,7 @@ def serve_proof(filename):
|
||||
Warranty.proofurl == f'{PROOF_URL_PREFIX}{filename}').first()
|
||||
downloadname = (warranty.prooffilename if warranty and warranty.prooffilename
|
||||
else filename)
|
||||
return send_from_directory(_proof_dir(), filename, as_attachment=True,
|
||||
return send_upload(_proof_dir(), filename, as_attachment=True,
|
||||
download_name=downloadname)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user