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.
47 lines
2.0 KiB
Python
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))
|