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:
1 parent
49bdedd938
commit
95554b4165
4 files changed
+90
-28
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/`, `/_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
@@ -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
@@ -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
@@ -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}
|
||||
Reference in new issue
Block a user