Security
StaticLayer is designed with a minimal attack surface and defense-in-depth controls. We do not claim "100% security" — the source, protocol, migrations and tests are public so the claims can be checked.
Security invariants (tested)
- Challenge single-use — one valid challenge is consumed at most once, atomically via D1
batch(). N concurrent requests with the same challenge → exactly one accepted. - Stateless admin session — HMAC-signed, absolute TTL,
__Host-cookie (Secure; HttpOnly; SameSite=Strict, noDomain). - Signed CSRF binding — admin mutations require a session-bound token, verified in constant time.
- Plain-text rendering — comments are rendered with
textContentonly; no raw HTML, no Markdown in v1. - Prepared SQL — all database access uses prepared statements + parameter binding.
- Secret separation — exactly three secrets with disjoint roles (
ADMIN_SECRET,SESSION_SECRET,POW_SECRET).
Public vs admin API
Public endpoints (/api/comments*) need no admin cookies. Admin endpoints (/api/admin*) require session + CSRF validation, restrictive caching, secure cookies, and fail closed.
Cross-origin (CORS)
Cross-origin calls are governed by an explicit allowlist: set the ALLOWED_ORIGINS var (comma-separated origins) in your worker config. Fail-closed — with an empty value, no cross-origin request is allowed and no Access-Control-Allow-Origin header is ever sent. Allowed origins are echoed back exactly (never *), including for admin routes; admin sessions remain same-origin by default.
Reactions
Reactions use the same cost-based integrity model as comments: single-use signed challenges, real Proof-of-Work, atomic anti-replay, per-article rate limiting and a minimum interval. Difficulty escalates per article to make stuffing progressively more expensive. A reaction row is an anonymous event — no user identifier, no IP. This is anti-stuffing by cost, not identity-based voting: repeated votes by one person are not detectable by design.
Zero-data anti-spam (v1.4)
Two behavioural layers sit on top of the Proof-of-Work, both designed to never read, store or persist any content or personal data (GDPR-neutral by default):
- Honeypot — the widget renders a hidden field that humans never see but bots fill. If it arrives filled, the server silently drops the submission and returns a plausible fake "pending" — the bot learns nothing and nothing is stored.
- Time gate (3s) — submissions arriving sooner than 3 seconds after the challenge was issued are rejected with
429. The issue time is recovered from the signed challenge (expiresAt − TTL), so the check keeps zero server state. The widget waits the gate client-side, so humans never notice.
Content heuristics and duplicate-content hashing are deliberately not enabled — they would require reading the comment text, against the zero-data principle.
Operational controls
- Route-scoped rate limiting (challenge / comments / login) — edge-local backstop, never IP-only.
- Request size caps (default 64 KB).
- Challenge TTL 5 minutes;
used_challengespurged after 24 hours. - Daily maintenance cron; failures propagate (never silent).
Threat model & evidence
- THREAT_MODEL.md — threats T1–T16 and invariants.
- SECURITY_REVIEW.md — full evidence, invariants I1–I16.
- SECURITY_AUDIT_REPORT.md — claim-by-claim matrix with file:line references.
- docs/cloudflare-assumptions.md — every Cloudflare behavior verified against official docs, dated.
Reporting a vulnerability
Please do not open a public issue for security vulnerabilities. Follow the process in SECURITY.md and report privately to the maintainers.
Known limitations (honest)
- Proof-of-Work raises the cost of automated submissions; it is not absolute spam prevention.
- Rate limiting is per-location and eventually consistent (Cloudflare-documented).
- Remote D1 concurrency is documented as pending empirical validation via
wrangler dev --remotebefore commercial launch.