From 95554b4165f93c6246d0139b7db74300f1a9bf23 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 26 Sep 2026 01:59:08 +0000 Subject: [PATCH] contact messages: allow all attachment types, enforce downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (, 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. --- AGENTS.md | 2 +- docs/messages.md | 21 ++++++++++------ pagerite/files.py | 60 +++++++++++++++++++++++++++++++++++++++++--- pagerite/messages.py | 35 +++++++++++++------------- 4 files changed, 90 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8ae252e..9eb7be2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/messages.md b/docs/messages.md index e994f79..3239c85 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -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 diff --git a/pagerite/files.py b/pagerite/files.py index 17ec5f6..bdc512d 100644 --- a/pagerite/files.py +++ b/pagerite/files.py @@ -8,7 +8,9 @@ images and SVGs are recompressed into AVIF/WebP/JPEG derivatives ``.orig`` (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 (, +#: 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) diff --git a/pagerite/messages.py b/pagerite/messages.py index f07ac21..f73db60 100644 --- a/pagerite/messages.py +++ b/pagerite/messages.py @@ -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}