- Session tokens are generated with
crypto/randas 21 random bytes encoded with unpadded base64url ([A-Za-z0-9_-]{28}). Standard base64 characters+,/, and padding=are rejected. - The raw session token is never stored. It is HMAC-SHA256 hashed with
SESSION_HASH_SECRET; that private HMAC value is the session row's primary key and is never exposed through the API. - Each session also has a random public UUID used for listing and remote revocation. Exposing this identifier does not expose the cookie token or its HMAC.
- The
sessioncookie isHttpOnly,SameSite=Strict,Secure(when TLS is detected orTRUST_PROXY=trueandX-Forwarded-Proto: https),Path=/,Max-Age=604800(7 days). - On every authenticated request,
RefreshSessionrefreshes the sliding TTL only when the session is within the inner half of the TTL window (expires_at < now + TTL/2); sessions in the outer half are still validated but not re-stamped. Sessions expire 7 days after last use or 30 days (720 h, configurable viaSESSION_ABSOLUTE_TTL_HOURS) after creation, whichever comes first. - A background goroutine sweeps expired sessions from PostgreSQL every hour.
- Password changes revoke all other sessions for the user atomically in a single transaction.
- Each user is limited to 100 sessions. Session creation is serialized per user and removes the oldest excess rows, bounding listing and storage work.
- Active-session listing and revocation derive the user and current token from authenticated request state. Remote revocation uses an ownership-constrained database delete, returns the same not-found result for missing and unowned UUIDs, and refuses to revoke the current session. Current-session termination remains the logout operation.
- Minimum 8 characters, maximum 1024 characters.
- Hashed with Argon2id PHC format:
memory=19456 KiB,iterations=2,parallelism=1,saltSize=16 bytes,hashSize=32 bytes. - Concurrent hash operations are bounded by a semaphore (default 4, configurable
via
ARGON_MAX_CONCURRENCY) to cap memory usage (~19 MiB per hash). Requests waiting for a hash slot honor request cancellation. - Hashes are upgraded silently to current parameters on next login
(
NeedsRehashcheck). - A pre-computed decoy hash is verified when the email is not found, preventing timing-oracle user enumeration.
| Operation | Enforcement |
|---|---|
| Update user profile | userId path param must equal session userID (checked in handler) |
| Delete post | WHERE public_id = $1 AND user_id = $2 (atomic in DB) |
| Delete comment | WHERE public_id = $1 AND id = $2 AND user_id = $3 (atomic in DB) |
| Revoke remote session | Atomic delete constrained by public_id and authenticated user_id; missing and unowned IDs both return 404 |
| Follow/unfollow self | Blocked at service layer (currentUserID == targetUserID) |
| Read email field | Stripped in handler unless requester is the user |
| Upload image | Registered to userID; consumed atomically at post/avatar creation |
- All routes except
POST /sessions,POST /users,GET /health,GET /ready, andOPTIONSrequire a valid session cookie. - Adding a new public route requires explicit registration before the
RequireSessionmiddleware wrapper.
- Token bucket per policy, keyed by user id > session cookie > client IP.
- Implemented as a Lua script on Dragonfly (atomic HMGET + HMSET + PEXPIRE).
TRUST_PROXY=truehonors only parseableX-Forwarded-ForIP values for IP extraction; invalid values fall back toRemoteAddr. Enable it only behind an upstream proxy that overwrites forwarding headers.- Failure mode: configurable via
RATE_LIMIT_FAIL_OPEN(default false → fail-closed on Dragonfly error); fail-closed returns HTTP 503.RATE_LIMIT_FAIL_OPENapplies only to the token-bucket rate limiter. - Login throttle uses separate per-IP (5 failures) and per-email (50 failures)
counters in Dragonfly with 15 min TTL, cleared on successful login. When
Dragonfly is unavailable, the login throttle always fails open regardless of
RATE_LIMIT_FAIL_OPEN.
- Frontend upload and avatar actions reject files over 1 MB and MIME types outside JPEG, PNG, GIF, and WEBP before proxying bytes to the backend. These checks are usability and load-shedding controls; the backend remains the authoritative validator.
- File content is validated against magic bytes (not extension or MIME header):
JPEG
\xff\xd8\xff, PNG\x89PNG\r\n\x1a\n, GIFGIF8, WEBPRIFF....WEBP. - Multipart read is bounded to 1 MB + 1 byte; files exceeding 1 MB are rejected before any further processing.
- All accepted images are decoded and re-encoded to JPEG at 90% quality. Re-encoding strips embedded metadata (EXIF, GPS coordinates, ICC profiles) and eliminates polyglot payloads that pass the magic byte check. Re-encoded output is rejected if it exceeds 1 MB.
- Pixel dimensions are checked via
image.DecodeConfigbefore full decode; images exceeding 25 MP (e.g. 5000×5000) are rejected to guard against decompression bombs. - All stored images have content type
image/jpegregardless of the original upload format. - Filenames are 32 lowercase hex characters generated from
crypto/rand. - File serving validates the path segment against the 32-char hex pattern before proxying.
X-XSS-Protection: 0(legacy auditor disabled; CSP takes over)X-Content-Type-Options: nosniffX-Frame-Options: SAMEORIGINReferrer-Policy: no-referrerContent-Security-Policy: default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; connect-src 'self'Strict-Transport-Security: max-age=31536000; includeSubDomainswhen the request is HTTPS or trusted forwarded HTTPS
X-Content-Type-Options: nosniffX-Frame-Options: SAMEORIGINReferrer-Policy: strict-origin-when-cross-originCross-Origin-Opener-Policy: same-originPermissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=()Strict-Transport-Security: max-age=31536000; includeSubDomainsfor HTTPS requests- SvelteKit nonce-based
Content-Security-Policy:default-src 'self'; script-src 'self'plus nonce;style-src 'self'plus nonce;img-src 'self' data: blob:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'
The uploads proxy route (/uploads/[key]) also sets
Cross-Origin-Resource-Policy: cross-origin to allow image embedding from other
origins.
- Hashtag filter values are validated against
^[A-Za-z0-9_]{1,50}$before being interpolated into Meilisearch filter strings. - All other query parameters are passed as structured JSON fields, never as raw filter fragments.
OriginGuardmiddleware rejects mutating requests whereOrigindoes not match the request host.SameSite=Strictcookie blocks cross-site form submission.
- The master key is used once at startup to provision a one-year scoped API key
with only
search,documents.add, anddocuments.deleteactions on theusers,posts, andhashtagsindexes. - All subsequent Meilisearch operations use the scoped key; the master key is not retained in memory.