Files
thoughtsync/tests/test_security_headers.py
bvandeusen b6152ec18b
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
server: harden the surfaces a public deployment leaves exposed
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.
2026-08-21 22:01:37 -04:00

58 lines
2.2 KiB
Python

"""Headers every response carries once this is reachable from the internet.
Asserted on /api/health because it is the one route that needs no database and no
session — the headers are set in an after_request hook, so any response proves the
hook, and this suite has no Postgres.
"""
import pytest
from thoughtsync.app import create_app
@pytest.fixture
def app():
return create_app()
async def test_content_security_policy_locks_scripts_to_self(app):
resp = await app.test_client().get("/api/health")
csp = resp.headers["Content-Security-Policy"]
assert "script-src 'self'" in csp
# The two that matter most if anything ever reflects user text into the page.
assert "object-src 'none'" in csp
assert "frame-ancestors 'none'" in csp
# No blanket unsafe-inline for SCRIPT — style is the only place it's conceded.
assert "script-src 'self' 'unsafe-inline'" not in csp
async def test_link_preview_images_are_still_allowed(app):
resp = await app.test_client().get("/api/health")
csp = resp.headers["Content-Security-Policy"]
# A preview renders the og:image of an arbitrary host; both schemes, because a
# LAN install is served over http.
assert "img-src" in csp
assert "https:" in csp
assert "http:" in csp
async def test_sniffing_and_referrer_are_pinned(app):
resp = await app.test_client().get("/api/health")
assert resp.headers["X-Content-Type-Options"] == "nosniff"
assert resp.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
assert "camera=()" in resp.headers["Permissions-Policy"]
async def test_no_hsts_on_plain_http(app):
# A plain-HTTP LAN install must not be told to refuse the only scheme it serves.
resp = await app.test_client().get("/api/health")
assert "Strict-Transport-Security" not in resp.headers
async def test_hsts_when_a_proxy_terminated_tls(app):
resp = await app.test_client().get("/api/health", headers={"X-Forwarded-Proto": "https"})
hsts = resp.headers["Strict-Transport-Security"]
assert "max-age=" in hsts
# Scoped to this host: neither of these commits domains the app doesn't own.
assert "includeSubDomains" not in hsts
assert "preload" not in hsts