contact messages: inert .file attachment blobs, universal forced download
Rework attachment storage and serving per the refined separation
scheme:
- Non-media uploads are stored as {hash}.file blobs (store_attachment)
— the original extension never lands on the filesystem, so the store
directory holds nothing double-click-executable. The original name
lives only in the URL: /_f/{hash}/{original-name}. The .file suffix
itself is rejected in URLs.
- Serving is strictly separated: /_f/{name} serves media only (hash
with or without extension, .orig. still blocked) and 404s .file
names; /_f/{name}/{filename} serves only {name}.file blobs, never
media, never by hash alone.
- Every /_f/ response now carries bare 'content-disposition:
attachment' (the URL's last segment already names the download) and
x-content-type-options: nosniff — media included. Subresource
fetches (<img>, <video>, favicons, the zoom viewer) ignore the
disposition, so inline media is unaffected, while a direct
navigation can never render or sniff a store file into something
executable in the site's origin. The mime-based script-carrier
forcing and the header filename are gone (subsumed).
- Message uploads treat SVG as media (rasterized derivatives; an
as-is-served .svg is script-inert inside <img>), and undecodable
fake images fall through to the .file attachment path.
This commit is contained in:
1 parent
95554b4165
commit
6b34719397
4 files changed
+104
-97
No files matched your search
@@ -12,7 +12,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
|
||||
- `pagerite/` — Python backend package (hatchling build target).
|
||||
- `app.py` — thin FastAPI assembly: lifespan, `FastAPI(...)`, router includes (route ordering: api/tracking/messages/files routers, then `frontend.route(app, "/")`, then the pages catch-all last).
|
||||
- `state.py` — shared core, no routes: env-derived site constants, `data`/`kanta`, `analytics_store`, the fastapi-vue `frontend`, the render cache (`_html_response`, `_invalidate_pages`), the translator `dispatcher`, slug helpers, `@kanta.bootstrap` hooks.
|
||||
- `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`), file routes (`/_api/files`, `/_f/` — incl. `/_f/{name}/{filename}` forced-download attachments, `/_themes/`, `/_fonts/`, favicon settings).
|
||||
- `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`; `store_attachment` keeps non-media uploads as inert `{hash}.file` blobs), file routes (`/_api/files`, `/_f/` — media by hash, attachments only via `/_f/{hash}/{filename}`, all forced-download + nosniff, `/_themes/`, `/_fonts/`, favicon settings).
|
||||
- `api.py` — editor REST + WS: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, `/_translate/{key}`.
|
||||
- `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md). Both sockets also carry the contact-message protocol (docs/messages.md): `/_ws` routes token-bearing visitor frames, the analytics socket the admin inbox.
|
||||
- `messages.py` — visitor contact threads (docs/messages.md): strict message rendering (`html=False` markdown-it — threads never carry user markup), thread store ops, the visitor/admin socket protocols, and the public `/_contact/files` upload route.
|
||||
|
||||
+19
-12
@@ -92,18 +92,25 @@ injection point, fed only by the strict renderer.
|
||||
## Uploads
|
||||
|
||||
`PUT /_contact/files/{name}` (public, like the messaging socket) stores
|
||||
panel uploads in the content-addressed file store: raster images through
|
||||
the usual AVIF/WebP/JPEG derivative pipeline, **anything else as-is**,
|
||||
rendering as a download chip in the thread. Every file type is allowed
|
||||
(html, exe, svg…) — safety comes from enforced downloads rather than
|
||||
type filtering, verified twice: the chip anchor carries a `download`
|
||||
attribute, and the attachment link is `/_f/{name}/{original-filename}`,
|
||||
which the file server answers with `content-disposition: attachment`
|
||||
naming the original file (the name rides in the URL; there is no
|
||||
filename registry). The plain `/_f/{name}` form additionally forces a
|
||||
download whenever the stored type is a script carrier (html/xml/svg/
|
||||
js), so stripping the filename segment cannot turn an attachment into
|
||||
an in-origin page; all `/_f/` responses carry `nosniff`. Gates today: a
|
||||
panel uploads in the content-addressed file store. Images (raster and
|
||||
SVG) go through the usual AVIF/WebP/JPEG derivative pipeline and inline
|
||||
in the message — SVGs included: the extension-less link negotiates
|
||||
raster derivatives, and even an as-is-served `.svg` is script-inert
|
||||
inside an `<img>`. Everything else (videos, archives, html, exe…) is
|
||||
stored as an inert `{hash}.file` blob — the original extension is
|
||||
dropped on disk, so the store can never be double-click-executed — and
|
||||
linked as `/_f/{hash}/{original-name}`; the `.file` suffix exists only
|
||||
on the filesystem and is rejected in URLs. Serving is strictly
|
||||
separated: `/_f/{name}` serves media only (hash with or without
|
||||
extension), `.orig.` originals stay blocked, and the filename-carrying
|
||||
route serves only `{name}.file` blobs (never media, never by hash).
|
||||
Every `/_f/` response carries `content-disposition: attachment` (no
|
||||
filename needed — the URL's last segment names the download) and
|
||||
`x-content-type-options: nosniff`, so a store file can never be
|
||||
rendered or sniffed into something executable in the site's origin;
|
||||
subresource fetches (`<img>`, `<video>`, favicons) ignore the
|
||||
disposition, so inline media is unaffected. The chip anchors
|
||||
additionally carry a `download` attribute. Gates today: a
|
||||
session-shaped `x-contact-token` header and a 20 MB size cap. Admin
|
||||
uploads from the same composer go to the admin-gated `/_api/files`
|
||||
instead. Files uploaded but never referenced by a sent message
|
||||
|
||||
+61
-65
@@ -8,9 +8,11 @@ images and SVGs are recompressed into AVIF/WebP/JPEG derivatives
|
||||
``<hash>.orig<ext>`` (never served). Routes: upload/delete under
|
||||
``/_api/files``, the favicon settings endpoints, the /favicon.ico
|
||||
redirect to the configured icon, the ``/_f/`` server with
|
||||
Accept-negotiated formats (``/_f/{name}/{filename}`` serves the same
|
||||
file as a named forced download), and the user assets (``/_themes/``,
|
||||
``/_fonts/``).
|
||||
Accept-negotiated formats (``/_f/{name}/{filename}`` serves ``{name}.file``
|
||||
attachment blobs), and the user assets (``/_themes/``, ``/_fonts/``).
|
||||
All ``/_f/`` responses are forced downloads (``content-disposition:
|
||||
attachment`` — ignored by <img>/<video> subresource fetches) with
|
||||
``nosniff``.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -19,7 +21,7 @@ import mimetypes
|
||||
import tempfile
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlsplit
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import blake3
|
||||
import httpx
|
||||
@@ -217,6 +219,20 @@ def store_image(
|
||||
return digest
|
||||
|
||||
|
||||
def store_attachment(body: bytes) -> str:
|
||||
"""Store a non-media upload as an inert ``<hash>.file`` blob.
|
||||
|
||||
The original extension is deliberately dropped on disk (a stored
|
||||
``.exe``/``.html`` could otherwise be accidentally run from the
|
||||
filesystem); the original name lives only in the serving URL
|
||||
(``/_f/{hash}/{name}``), and ``.file`` blobs are served nowhere
|
||||
else. Returns the store name.
|
||||
"""
|
||||
name = blake3.blake3(body).hexdigest()[:12] + ".file"
|
||||
file_store.put(name, body)
|
||||
return name
|
||||
|
||||
|
||||
#: Pasted-URL image fetches (fetch_image) refuse bodies over this size.
|
||||
FETCH_MAXSIZE = 20 * 1024 * 1024
|
||||
|
||||
@@ -369,35 +385,6 @@ async def user_font_file(name: str, filename: str, request: Request) -> Response
|
||||
return await _serve_user_file(views.font_file(name, filename), request)
|
||||
|
||||
|
||||
#: Mimes that execute script when navigated to in the site's origin. Store
|
||||
#: files with such a type are always forced to download, so that stripping
|
||||
#: the file-name segment off an attachment URL cannot turn it into an
|
||||
#: in-origin page (see stored_file_download). Subresource fetches (<img>,
|
||||
#: favicons) ignore Content-Disposition and are unaffected.
|
||||
_SCRIPT_MIMES = {
|
||||
"text/html",
|
||||
"application/xhtml+xml",
|
||||
"text/xml",
|
||||
"application/xml",
|
||||
"image/svg+xml",
|
||||
"text/javascript",
|
||||
"application/javascript",
|
||||
"application/x-javascript",
|
||||
}
|
||||
|
||||
|
||||
def _content_disposition(filename: str) -> str:
|
||||
"""An attachment Content-Disposition for an original file name: an
|
||||
ASCII-safe quoted fallback plus the RFC 5987 UTF-8 form."""
|
||||
fallback = "".join(
|
||||
c if 32 <= ord(c) < 127 and c not in '";\\' else "_" for c in filename
|
||||
)
|
||||
return (
|
||||
f'attachment; filename="{fallback or "file"}"; '
|
||||
f"filename*=UTF-8''{quote(filename, safe='')}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/_f/{name}")
|
||||
async def stored_file(name: str, request: Request) -> Response:
|
||||
return _stored_response(name, request)
|
||||
@@ -405,16 +392,15 @@ async def stored_file(name: str, request: Request) -> Response:
|
||||
|
||||
@router.get("/_f/{name}/{filename}")
|
||||
async def stored_file_download(name: str, filename: str, request: Request) -> Response:
|
||||
"""The attachment form of a store URL: same file, always served as a
|
||||
download named after the original upload (the link form contact
|
||||
attachments carry, `/_f/{name}/{original}` — the file name lives in
|
||||
the URL, not the database). The name segment only labels the
|
||||
download; lookup is by ``name`` alone.
|
||||
"""The attachment URL shape. ``{name}`` identifies a ``{name}.file``
|
||||
blob (inert on disk, never served without the filename segment);
|
||||
``{filename}`` is the original upload name and lives only in this
|
||||
URL — the browser names the download after the last path segment.
|
||||
"""
|
||||
return _stored_response(name, request, Path(filename).name or "file")
|
||||
return _stored_response(name, request, attachment=True)
|
||||
|
||||
|
||||
def _stored_response(name: str, request: Request, filename: str = "") -> Response:
|
||||
def _stored_response(name: str, request: Request, *, attachment: bool = False) -> Response:
|
||||
"""Serve a file from the content-addressed store (immutable: the name
|
||||
is its own hash, so cache forever). Bodies are served from the RAM
|
||||
cache, zstd-compressed when the client accepts it and compression
|
||||
@@ -425,36 +411,51 @@ def _stored_response(name: str, request: Request, filename: str = "") -> Respons
|
||||
only when the Accept header lists it explicitly — ``image/avif`` →
|
||||
AVIF, ``image/webp`` → WebP, anything else (including ``image/*`` and
|
||||
``*/*``) → JPEG. An explicit extension pins the format. ``.orig.``
|
||||
originals are internal (they may carry EXIF data) and never served.
|
||||
originals are internal (they may carry EXIF data) and never served,
|
||||
and ``.file`` attachment blobs are only served through the
|
||||
filename-carrying URL (stored_file_download), never by hash.
|
||||
|
||||
Every response carries ``nosniff``; a ``filename`` (attachment URLs)
|
||||
or a script-carrier mime forces a download via Content-Disposition."""
|
||||
Every response is ``content-disposition: attachment`` with
|
||||
``nosniff``: subresource fetches (<img>, <video>, favicons) ignore
|
||||
the disposition, but a direct navigation always downloads and the
|
||||
body is never sniffed into something executable in this origin."""
|
||||
if ".orig." in name:
|
||||
raise HTTPException(404)
|
||||
etag = name
|
||||
vary = ""
|
||||
entry = file_store.get(name)
|
||||
if entry is None and "." not in name:
|
||||
# Extension-less image link: negotiate avif/webp/jpg by Accept.
|
||||
vary = "accept"
|
||||
accept = request.headers.get("accept", "")
|
||||
if "image/avif" in accept:
|
||||
order = ("avif", "webp", "jpg")
|
||||
elif "image/webp" in accept:
|
||||
order = ("webp", "jpg", "avif")
|
||||
else:
|
||||
order = ("jpg", "webp", "avif")
|
||||
for ext in order:
|
||||
etag = f"{name}.{ext}"
|
||||
entry = file_store.get(etag)
|
||||
if entry is not None:
|
||||
break
|
||||
if attachment:
|
||||
etag = f"{name}.file"
|
||||
entry = file_store.get(etag)
|
||||
else:
|
||||
if name.endswith(".file"):
|
||||
raise HTTPException(404)
|
||||
entry = file_store.get(name)
|
||||
if entry is None and "." not in name:
|
||||
# Extension-less image link: negotiate avif/webp/jpg by Accept.
|
||||
vary = "accept"
|
||||
accept = request.headers.get("accept", "")
|
||||
if "image/avif" in accept:
|
||||
order = ("avif", "webp", "jpg")
|
||||
elif "image/webp" in accept:
|
||||
order = ("webp", "jpg", "avif")
|
||||
else:
|
||||
order = ("jpg", "webp", "avif")
|
||||
for ext in order:
|
||||
etag = f"{name}.{ext}"
|
||||
entry = file_store.get(etag)
|
||||
if entry is not None:
|
||||
break
|
||||
if entry is None:
|
||||
raise HTTPException(404)
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304)
|
||||
body, compressed = entry
|
||||
headers = {"etag": etag, "cache-control": "public, max-age=31536000, immutable"}
|
||||
headers = {
|
||||
"etag": etag,
|
||||
"cache-control": "public, max-age=31536000, immutable",
|
||||
"content-disposition": "attachment",
|
||||
"x-content-type-options": "nosniff",
|
||||
}
|
||||
if compressed is not None and "zstd" in request.headers.get("accept-encoding", ""):
|
||||
headers["content-encoding"] = "zstd"
|
||||
vary = f"{vary}, accept-encoding".lstrip(", ")
|
||||
@@ -462,9 +463,4 @@ def _stored_response(name: str, request: Request, filename: str = "") -> Respons
|
||||
if vary:
|
||||
headers["vary"] = vary
|
||||
mime = mimetypes.guess_type(etag)[0] or "application/octet-stream"
|
||||
if filename:
|
||||
headers["content-disposition"] = _content_disposition(filename)
|
||||
elif mime in _SCRIPT_MIMES:
|
||||
headers["content-disposition"] = _content_disposition(name)
|
||||
headers["x-content-type-options"] = "nosniff"
|
||||
return Response(body, media_type=mime, headers=headers)
|
||||
+23
-19
@@ -34,7 +34,7 @@ from markdown_it import MarkdownIt
|
||||
from markdown_it.common.utils import escapeHtml
|
||||
|
||||
from pagerite.data import ContactMessage, ContactThread
|
||||
from pagerite.files import _ext, _hash_name, file_store, store_image
|
||||
from pagerite.files import _ext, store_attachment, store_image
|
||||
from pagerite.state import data, kanta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -302,13 +302,14 @@ async def admin_frame(ws: WebSocket, raw: dict) -> None:
|
||||
|
||||
# --- Uploads -----------------------------------------------------------------
|
||||
|
||||
#: Contact-panel uploads: raster images get the usual derivative pipeline
|
||||
#: and inline in the message; every other type (html, exe, svg, anything)
|
||||
#: is stored as-is and renders as a download chip whose link carries the
|
||||
#: original file name: /_f/{name}/{filename}. Downloads are enforced
|
||||
#: twice — the chip anchor has a download attribute and that URL is served
|
||||
#: content-disposition: attachment — so no file type needs refusing.
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".heic", ".heif"}
|
||||
#: Contact-panel uploads: images (raster and SVG) go through the usual
|
||||
#: derivative pipeline and inline in the message — SVGs included: the
|
||||
#: extension-less link negotiates raster derivatives, and even an
|
||||
#: as-is-served .svg is inert inside an <img> (and downloads when
|
||||
#: navigated to). Every other type is stored as an inert {hash}.file
|
||||
#: blob (store_attachment — no original extension on disk) and links as
|
||||
#: /_f/{hash}/{original-name}; the .file suffix never appears in URLs.
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif", ".heic", ".heif"}
|
||||
UPLOAD_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -319,10 +320,11 @@ async def contact_upload(name: str, request: Request) -> dict:
|
||||
Public like the messaging socket (only /_api is gated): the caller
|
||||
must present a session-shaped token, size is capped, and abuse
|
||||
limiting is meant to ride on the proof-of-work stamp once plugged in.
|
||||
Images get the usual AVIF/WebP/JPEG derivatives and are referenced
|
||||
extension-less; any other file is stored as-is and referenced with
|
||||
its original name in the path (served as a forced download). Returns
|
||||
the /_f/ path and whether the file is an inline-able image.
|
||||
Images (raster and SVG) get the usual derivative treatment and are
|
||||
referenced extension-less; any other file is stored as an inert
|
||||
{hash}.file blob and referenced as /_f/{hash}/{original-name}, which
|
||||
is always served as a forced download. Returns the /_f/ path and
|
||||
whether the file is an inline-able image.
|
||||
"""
|
||||
if not TOKEN_RE.fullmatch(request.headers.get("x-contact-token", "")):
|
||||
raise HTTPException(403, "no session")
|
||||
@@ -341,11 +343,13 @@ async def contact_upload(name: str, request: Request) -> dict:
|
||||
ext = _ext(orig)
|
||||
if ext in IMAGE_EXTS:
|
||||
stored = await asyncio.to_thread(store_image, body, ext, derive=ext != ".gif")
|
||||
# Decodable images come back extension-less (GIFs keep .gif); an
|
||||
# undecodable body is stored as-is and attaches like any file.
|
||||
if "." not in stored or ext == ".gif":
|
||||
# Decodable images come back extension-less (GIFs and as-is SVGs
|
||||
# keep their extension); an undecodable body attaches like any file.
|
||||
if "." not in stored or ext in (".gif", ".svg"):
|
||||
return {"path": f"/_f/{stored}", "image": True, "name": orig}
|
||||
else:
|
||||
stored = _hash_name(body, orig)
|
||||
await asyncio.to_thread(file_store.put, stored, body)
|
||||
return {"path": f"/_f/{stored}/{quote(orig, safe='')}", "image": False, "name": orig}
|
||||
stored = await asyncio.to_thread(store_attachment, body)
|
||||
return {
|
||||
"path": f"/_f/{stored.removesuffix('.file')}/{quote(orig, safe='')}",
|
||||
"image": False,
|
||||
"name": orig,
|
||||
}
|
||||
Reference in new issue
Block a user