contact messages: allow all attachment types, enforce downloads

Drop the script-carrier blocklist: any file type (html, exe, svg, ...)
may be attached now, with safety from enforced downloads instead of
type filtering, verified twice:

- the attachment chip anchor carries a download attribute (already),
- the link is /_f/{name}/{original-filename}, a new file-store route
  that answers content-disposition: attachment naming the original
  file (ASCII-safe fallback + RFC 5987 UTF-8 form). The filename rides
  in the URL itself — no filename registry in the database.

Close the residual gap on the plain /_f/{name} route: a stored file
whose mime executes script in origin (html/xml/svg/js) is forced to
download there too, so stripping the filename segment off an
attachment URL cannot turn it into an in-origin page. All /_f/
responses now carry x-content-type-options: nosniff. Subresource
fetches (<img>, favicons) ignore Content-Disposition, so inline
images and icons are unaffected.

Undecodable fake images (store_image falls back to as-is storage) now
attach as files instead of being marked inline images.
This commit is contained in:
LeoVasanko committed 2026-09-26 01:59:08 +00:00
1 parent 49bdedd938
commit 95554b4165
4 files changed
+90 -28

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/`, `/_themes/`, `/_fonts/`, favicon settings).
- `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).
- `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.
+14 -7
View File
@@ -94,13 +94,20 @@ injection point, fed only by the strict renderer.
`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. The one exclusion is script
carriers (svg/html/xhtml/js/xml…) — an attachment URL can always be
opened directly in the site's origin, where such a file would execute.
Gates today: a session-shaped `x-contact-token` header, a 20 MB size cap
and the type blocklist. Admin uploads from the same composer go to the
admin-gated `/_api/files` instead. Files uploaded but never referenced by
a sent message currently linger (no orphan GC).
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
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
currently linger (no orphan GC).
## Storage
+57 -3
View File
@@ -8,7 +8,9 @@ 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, and the user assets (``/_themes/``, ``/_fonts/``).
Accept-negotiated formats (``/_f/{name}/{filename}`` serves the same
file as a named forced download), and the user assets (``/_themes/``,
``/_fonts/``).
"""
import asyncio
@@ -17,7 +19,7 @@ import mimetypes
import tempfile
from contextlib import suppress
from pathlib import Path
from urllib.parse import urlsplit
from urllib.parse import quote, urlsplit
import blake3
import httpx
@@ -367,8 +369,52 @@ 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)
@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.
"""
return _stored_response(name, request, Path(filename).name or "file")
def _stored_response(name: str, request: Request, filename: str = "") -> 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
@@ -379,7 +425,10 @@ async def stored_file(name: str, request: Request) -> Response:
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.
Every response carries ``nosniff``; a ``filename`` (attachment URLs)
or a script-carrier mime forces a download via Content-Disposition."""
if ".orig." in name:
raise HTTPException(404)
etag = name
@@ -413,4 +462,9 @@ async def stored_file(name: str, request: Request) -> Response:
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)
+18 -17
View File
@@ -26,6 +26,7 @@ import re
import secrets
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import quote
import blake3
from fastapi import APIRouter, HTTPException, Request, WebSocket
@@ -301,13 +302,13 @@ async def admin_frame(ws: WebSocket, raw: dict) -> None:
# --- Uploads -----------------------------------------------------------------
#: Contact-panel uploads: raster images get the usual derivative pipeline,
#: everything else is stored as-is and renders as a download chip — except
#: script carriers, which are refused outright: an attachment URL can
#: always be opened directly in the site's origin, where served HTML/JS/
#: SVG/XML would execute.
#: 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"}
BLOCKED_EXTS = {".svg", ".html", ".htm", ".xhtml", ".xht", ".js", ".mjs", ".xml"}
UPLOAD_MAX_BYTES = 20 * 1024 * 1024
@@ -316,12 +317,12 @@ async def contact_upload(name: str, request: Request) -> dict:
"""Store a contact-panel upload in the content-addressed store.
Public like the messaging socket (only /_api is gated): the caller
must present a session-shaped token, size is capped, script-carrying
types are refused, 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 for download. Returns the /_f/ path and
whether the file is an inline-able image.
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.
"""
if not TOKEN_RE.fullmatch(request.headers.get("x-contact-token", "")):
raise HTTPException(403, "no session")
@@ -340,11 +341,11 @@ 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")
image = True
elif ext in BLOCKED_EXTS:
raise HTTPException(415, "this file type is not allowed")
# 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":
return {"path": f"/_f/{stored}", "image": True, "name": orig}
else:
stored = _hash_name(body, orig)
await asyncio.to_thread(file_store.put, stored, body)
image = False
return {"path": f"/_f/{stored}", "image": image, "name": orig}
return {"path": f"/_f/{stored}/{quote(orig, safe='')}", "image": False, "name": orig}