Beckham Share exposes a public upload form to the internet, so its security posture is deliberately split: a tightly constrained anonymous tier and an identity-gated member tier. This document covers the authentication model, the abuse controls, the data handled, and the threat model.
- Protocol. OIDC Authorization Code flow against Authentik
(
auth.beckham.ai), via Authlib. Seeapp/auth.py. - Session. On success the app stores
{sub, email, name, groups}in a signed,Secure,SameSite=Laxsession cookie (Starlette'sSessionMiddleware, signed withSECRET_KEY). The app owns its own session; it does not rely on a proxy to inject identity. - Two-layer group gate. The workspace is restricted to members of the
dropboxgroup in two independent places:- At Authentik — the application is bound to the group, so non-members can't complete the flow at the IdP.
- In-app backstop —
/appand every management route re-check thegroupsclaim (CurrentUser.in_required_group).
- A missing
groupsclaim is refused. Authentik is configured to send the claim (agroupsscope mapping is bound to the provider), so a token without one means the provider's configuration has drifted — not that the user belongs to nothing. Admitting them would quietly reduce a deliberately two-layer gate to Authentik's binding alone, which is the layer this check exists not to depend on. The request is refused and the reason is logged. - Escape hatch.
ALLOW_MISSING_GROUPS_CLAIM(defaultfalse) restores the old behaviour so a broken scope mapping can be repaired without locking the members out of their own files. It re-opens the gap it exists to close, so the app logs a warning at startup for as long as it is set. Clear it once the mapping is fixed.
The landing page is a public form. Four layers keep it from becoming a dump:
Anonymous uploads are capped at ANON_MAX_UPLOAD_BYTES (default 100 MiB),
enforced while streaming — the upload is aborted and the partial file deleted
the moment it exceeds the limit (app/storage.py), so an oversized file is never
fully buffered. Members have a separate, larger cap (MAX_UPLOAD_BYTES).
app/ratelimit.py enforces a rolling-window budget:
ANON_UPLOADS_PER_HOUR(default 5) in the last hour, andANON_UPLOADS_PER_DAY(default 20) in the last 24 hours.
The count is taken from the upload_events audit rows, keyed by IP or
fingerprint (OR), so rotating just one signal does not reset the budget. The
two keys carry different weight on purpose: the address is established by the
infrastructure (see TRUSTED_PROXIES below), while the browser fingerprint is
supplied by the client and can be changed at will. The fingerprint's job is to
catch one device rotating addresses; it is an additional key, never a
substitute for the address.
Because it counts rows that already exist in the database, the limit holds across
worker processes and restarts with no separate store. Over-budget requests get
429 with a Retry-After header.
Anonymous links always expire after ANON_SHARE_EXPIRY_HOURS (default 24 h);
there is no "never" option for them. The blob carries the same hard expiry.
Every upload writes an upload_events row for review — see below.
app/fingerprint.py records, per upload:
-
Client IP — the address the request arrived from, unless it arrived from a peer listed in
TRUSTED_PROXIES, in which case that peer'sX-Forwarded-For/X-Real-IPis believed instead. The real client IP is preserved to the front through HAProxy's PROXY protocol.X-Forwarded-Foris read right to left: trusted hops are skipped and the first untrusted address is the closest one a trusted proxy actually observed. Reading left to right would return whatever the client itself put in the header, because proxies append. This matters because the anonymous rate limit is keyed on the address — an address the client can choose is an address that resets the budget. -
User agent — raw, plus a parsed
browser/os/devicebreakdown for human-readable review. -
Accept-Language.
-
Fingerprint hash — a SHA-256 over either the client-supplied browser fingerprint (canvas / WebGL / screen / timezone signals, richer) or, for JS-less clients, a header/network-derived basis so they are still grouped.
-
Raw fingerprint bundle (
fingerprint_data) for later inspection.
Fingerprinting here is a deterrent and an audit aid, not a security boundary. It can be spoofed; it exists to make casual abuse traceable and rate-limitable, not to authenticate anyone.
- Filename privacy. The original filename never appears in a URL or an
on-disk path. Blobs are stored under
{DATA_DIR}/blobs/<uuid>; the real name lives only in the database and is restored in the download'sContent-Disposition. - Opaque links. Share URLs contain only a random UUID token; they are unguessable and reveal nothing about the file.
- Integrity. Each blob's SHA-256 is computed on upload and stored.
- Deletion. Deleting a file soft-deletes the row and removes the blob from disk; expired/revoked links stop serving immediately.
- Secrets.
SECRET_KEY,DB_PASSWORD, the OIDC client secret, and SMTP credentials live only in the host's.env(gitignored) — never in the repo.
- HTTPS everywhere. TLS is terminated by the shared Caddy front (Let's Encrypt); the smoke test asserts a valid certificate after each deploy.
- Database isolation. PostgreSQL is on an internal Docker network with no host port published.
- Canonical host. The session cookie and OIDC redirect URIs are bound to
BASE_URL; other hostnames redirect the authenticated flow there, so a cookie can't be set on an unexpected origin.
| Threat | Mitigation |
|---|---|
| Anonymous form used to host abusive material | Size cap, rate limit, short auto-expiry, IP + fingerprint audit trail. |
| Link guessing / enumeration | UUID tokens; no filename or sequential ID in URLs. |
| Unauthorized workspace access | OIDC + group binding at the IdP, re-checked in-app. |
| Public share page abused as a spam relay | Server-side email is members-only; anonymous uses mailto:. |
| Oversized-upload resource exhaustion | Streaming write with an early abort + partial-file cleanup. |
| Session forgery | Signed session cookie (SECRET_KEY); Secure + SameSite=Lax. |
| Spoofed client IP (to reset the rate limit or poison the audit trail) | Forwarded headers are honoured only from peers in TRUSTED_PROXIES, and the chain is read right to left so client-appended hops are discarded. Everything else is attributed to the address it arrived from. |
- Set a strong, unique
SECRET_KEYin production; rotating it invalidates all sessions. - Keep the
groupsscope mapping bound to the provider. Without it nobody reaches the workspace, which is the intended failure direction — checkdocker compose logs appfor the "no groups claim" warning if sign-in starts ending in a 403. - Review the
upload_eventstable periodically for anomalous IPs/fingerprints. - Tune
ANON_*limits to the host's tolerance; they are all environment-driven.