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
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:
+77
-4
@@ -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"]})
|
||||
|
||||
Reference in New Issue
Block a user