server: harden the surfaces a public deployment leaves exposed
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 9s
CI & Build / Python tests (push) Failing after 11s
CI & Build / Build & push image (push) Successful in 33s

On a LAN the login form is reachable by people you already trust. Exposed, it is
reachable by everyone, and nothing in front of it was counting.

Three credential routes — /login, /register and /device-login — now throttle.
Every attempt is counted against BOTH the account and the calling address, and
either can refuse it. The account key is the one that matters and the one that
cannot be forged: it stops stuffing against a known email no matter how many
addresses the attempts arrive from. The address key bounds one source spraying
many accounts, and is best-effort by nature — behind a proxy it comes from
X-Forwarded-For, which a caller can set to anything if the app is exposed
directly. That is exactly why it isn't the only key.

The check runs BEFORE the password is verified, which is the other half of what
this protects. bcrypt is deliberately slow; an unauthenticated caller who can
trigger it without limit has a CPU exhaustion primitive as well as a guessing
one. Sliding rather than fixed windows, because a fixed one lets twice the limit
through across a boundary. Bucket count is capped so a rotating forged header
can't turn the limiter into the exhaustion it prevents.

A sign-in against an email with no account now spends a real bcrypt against a
throwaway hash first. Without it "no such account" returned in microseconds
while a wrong password took ~100ms, which is a reliable oracle for which emails
are registered here.

Every response carries a CSP with script-src 'self', object-src 'none' and
frame-ancestors 'none', plus nosniff, a referrer policy and a permissions
policy. The app has no inline and no third-party scripts, so this concedes
nothing; the exceptions are honest — inline STYLE (Vue writes it itself for
v-show and the FLIP), and remote images (a link preview renders the og:image of
an arbitrary host, over either scheme, since a LAN install is served over http).
HSTS only where the request already arrived over TLS, and scoped to the one
host: no includeSubDomains, no preload, neither of which is this app's to
commit.

X-Forwarded-Proto detection moved into one `_is_https()` — the session cookie's
Secure flag and HSTS are the same question, and answering it twice is how the
two drift apart.

docs/public-hosting.md is the rest of it: the four things only the operator can
do (close registration, terminate TLS and forward the scheme, stop publishing
the app port, back up the attachment volume as well as the database), and an
honest list of what the app does NOT have — no email verification, no password
reset, no second factor, no per-user quota, no audit log. Those aren't blockers
for an instance whose accounts are people you know. They're the reason not to
leave signups open to strangers.
This commit is contained in:
2026-08-21 22:01:37 -04:00
parent 16f86bef93
commit b6152ec18b
8 changed files with 631 additions and 7 deletions
+77 -4
View File
@@ -31,6 +31,19 @@ STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
mimetypes.add_type("application/manifest+json", ".webmanifest")
def _is_https() -> bool:
"""Whether this request reached us over TLS — directly, or through a proxy that
terminated it and said so in X-Forwarded-Proto.
Shared by the session cookie's Secure flag and by HSTS, because they are the same
question and answering it twice is how the two drift apart.
"""
if not has_request_context():
return False
forwarded = request.headers.get("X-Forwarded-Proto", "").split(",")[0].strip().lower()
return forwarded == "https" or request.is_secure
class _AutoSecureSessionInterface(SecureCookieSessionInterface):
"""Mark the session cookie `Secure` whenever the request arrived over HTTPS —
directly, or via a TLS-terminating reverse proxy that sets X-Forwarded-Proto.
@@ -42,10 +55,7 @@ class _AutoSecureSessionInterface(SecureCookieSessionInterface):
"""
def get_cookie_secure(self, app: Quart) -> bool:
if not has_request_context():
return False
forwarded = request.headers.get("X-Forwarded-Proto", "").split(",")[0].strip().lower()
return forwarded == "https" or request.is_secure
return _is_https()
def create_app() -> Quart:
@@ -101,6 +111,69 @@ def create_app() -> Quart:
with suppress(asyncio.CancelledError):
await task
@app.after_request
async def _security_headers(response):
"""Headers a publicly-reachable instance should be sending.
None of these change how the app behaves for a legitimate caller; they narrow
what a browser will do if something else goes wrong.
The CSP is the substantive one. `script-src 'self'` means that even if some
future path did manage to reflect user text into the page, the browser would
refuse to run it — the app has no inline scripts and no third-party scripts,
so nothing legitimate is given up. The exceptions are honest ones:
- `style-src 'unsafe-inline'` — Vue writes inline styles itself (`v-show`
toggling display, TransitionGroup's FLIP setting transforms). Inline
STYLE is not an execution primitive the way inline script is.
- `img-src https: http:` — link previews render the remote og:image of
whatever was linked, which is an arbitrary host by definition. Both
schemes, because a LAN install is served over http and would otherwise
lose every preview image; on an https instance the browser blocks the
http ones as mixed content anyway, so naming it concedes nothing.
- `blob:`/`data:` — attachment previews and the desktop's blob URI scheme.
`frame-ancestors 'none'` replaces the older X-Frame-Options and is what stops
the app being framed for clickjacking; `form-action 'self'` stops a form from
being pointed at another origin.
"""
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"base-uri 'self'; "
"object-src 'none'; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https: http:; "
"font-src 'self' data:; "
"media-src 'self' blob:; "
"connect-src 'self'",
)
# Content-type sniffing turns a file we said was text into whatever the bytes
# look like. The attachment route already sets this; every other response
# deserves it too.
response.headers.setdefault("X-Content-Type-Options", "nosniff")
# Note titles and label names end up in the URL of a search or a label lens,
# and a full Referer would hand them to any site a link preview points at.
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
# Nothing here uses a camera, a microphone or a location, so nothing embedded
# in a page should be able to ask on its behalf.
response.headers.setdefault(
"Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()"
)
# HSTS only where the request already arrived over TLS — the same detection
# the session cookie uses. Sending it on a plain-HTTP LAN install would tell
# the browser to refuse the only scheme that install serves.
#
# Scoped to this host: no `includeSubDomains` and no `preload`, both of which
# commit domains this app does not own. A browser still remembers the policy
# for up to a year after the header stops being sent, which is the point of
# it — worth knowing before putting a hostname behind TLS temporarily.
if _is_https():
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000")
return response
@app.get("/api/health")
async def health():
return jsonify({"status": "ok", "version": app.config["APP_VERSION"]})