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:
LeoVasanko committed 2026-09-26 03:35:08 +00:00
1 parent 95554b4165
commit 6b34719397
4 files changed
+104 -97

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}