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, no Domain).
  • Signed CSRF binding — admin mutations require a session-bound token, verified in constant time.
  • Plain-text rendering — comments are rendered with textContent only; 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_challenges purged after 24 hours.
  • Daily maintenance cron; failures propagate (never silent).

Threat model & evidence

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 --remote before commercial launch.