Files
shopdb-flask/shopdb/utils/uploads.py
cproudlock c7dffce81e 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.
2026-08-14 13:46:53 -04:00

47 lines
2.0 KiB
Python

"""Serving user-uploaded files without handing the browser a script.
Uploads reach these routes from people, and one of the accepted image types is
not inert: an SVG is an XML document that may carry <script>, and it is wanted
for floor-plan maps and branding, where vector is the right format. Loaded
through an <img> tag a script inside it never runs, so the tiles and maps in
this app were never the risk. Opening the file's own URL is - and the
application image route is public, so the URL is reachable without a session.
Rather than banning the format that has a real use, every uploaded file is
served with headers that make the document case inert:
Content-Security-Policy: default-src 'none'; sandbox
No script, no fetch, no plugin, and a unique opaque origin. This is what
stops an SVG (or an HTML file that talked its way past an allowlist)
executing in the site's origin and reading a token out of localStorage.
X-Content-Type-Options: nosniff
A file whose extension and bytes disagree is not re-typed by the browser
into something executable.
Both are cheap and apply to every upload route, so a new one added later starts
from the same place instead of repeating the reasoning.
"""
from flask import send_from_directory
# 'sandbox' with no allow-* tokens: opaque origin, scripts blocked, forms and
# popups blocked. default-src 'none' is belt and braces for a UA that ignores
# sandbox in a header.
UPLOAD_CSP = "default-src 'none'; sandbox"
def harden_upload_response(response):
"""Apply the inert-document headers to a response serving an upload."""
response.headers['Content-Security-Policy'] = UPLOAD_CSP
response.headers['X-Content-Type-Options'] = 'nosniff'
return response
def send_upload(directory, filename, **kwargs):
"""send_from_directory, with the upload headers applied.
Any keyword send_from_directory takes is passed through, so a route that
already sends as an attachment keeps doing so.
"""
return harden_upload_response(send_from_directory(directory, filename, **kwargs))