Skip to content

Security: oitray/domain-drop-watcher

Security

SECURITY.md

Security Policy

Reporting a vulnerability

Use GitHub Private Vulnerability Reporting:

https://github.com///security/advisories/new

Please include:

  • Description of the issue
  • Steps to reproduce
  • Affected versions (main branch)
  • Any suggested mitigations

Do not open a public GitHub issue for security vulnerabilities.

Scope

This repository only. Cloudflare platform issues should be reported to Cloudflare at https://www.cloudflare.com/trust-hub/vulnerability-disclosure-policy/.

Supported versions

The main branch is the only supported version. There are no versioned releases with long-term security support.

Bug bounty

This is a FOSS project with no bug bounty program.

Authentication

Allowlist enumeration

POST /login/email-code always returns 202 Accepted regardless of whether the email is in the user allowlist. Both the allowlisted and non-allowlisted paths perform equivalent synchronous D1 work (SELECT + INSERT into login_attempts) before responding; the email send is deferred via ctx.waitUntil. This is best-effort enumeration resistance — it is not constant-time. Network jitter and D1 query-planner cache variance can produce measurable differences in practice. Operators with strict confidentiality requirements around the allowlist should layer Cloudflare Access in front.

Mail-scanner token pre-consumption

Emails sent by the login flow contain no URLs. The message body is a plain 6-digit code and a short instruction to type it back on the login page. Corporate mail security products (Proofpoint, Microsoft Defender, Mimecast) automatically follow every URL in incoming messages to sandbox for malware; a URL-based token would be consumed before the operator reads the email. Typed codes have no URL to consume.

Code in email subject line

The subject line is domain-drop-watcher sign-in code: <6 digits>. The code is deliberately in the subject so most mail clients show it without opening the message. Accepted tradeoffs: the code is visible in lock-screen push-notification previews, may be indexed by mail-server search, and is logged in SMTP relay headers. Operators concerned about lock-screen leakage should use passkey auth or disable lock-screen notification previews on their devices.

Code replay defenses

  • Hashed at rest: SHA-256("code=" || code || "|email=" || email || "|secret=" || SESSION_SECRET). Domain separators prevent concatenation-collision attacks. Plaintext never persists beyond the email body and the user's memory.
  • One-time use: the redemption UPDATE is atomic (WHERE code_hash = ? AND used_at IS NULL); changes=0 on concurrent redemption causes rejection.
  • 10-minute expiry enforced at both INSERT (expires_at) and SELECT time.
  • Per-code brute-force cap: verify_attempts column, reject at ≥ 5.

Session cookies

  • Cookie name: dropwatch_session
  • Value format: <session_id>.<hmac_sig> — both halves strict base64url ([A-Za-z0-9_-]). Value rejected if it does not match ^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ or does not split into exactly two parts.
  • session_id: 32 random bytes, base64url-encoded (43 chars). hmac_sig: HMAC-SHA256(session_id, SESSION_SECRET), base64url-encoded (43 chars).
  • Flags: HttpOnly, Secure, SameSite=Lax, Path=/, Max-Age=43200 (12 hours).
  • On every request: cookie parsed → regex-validated → HMAC constant-time verified → session row looked up in D1 → session must not be expired and user must not be disabled.

SESSION_SECRET is a 32-byte random value generated by the build script and stored as a Cloudflare Secret. It never appears in any HTTP response. Rotating it (delete Secret + redeploy) invalidates all active sessions.

CSRF mitigation

SameSite=Lax on the session cookie prevents cross-site form POSTs from carrying the cookie in most browsers. All state-changing POST endpoints that use cookie authentication additionally check the Origin header and reject requests where it does not match the Worker's origin. A missing or null Origin header is treated as a mismatch and rejected. Routes authenticated exclusively via Authorization: Bearer (break-glass, API scripts) are exempt from the Origin check — they are not cookie-backed and therefore not CSRF-exposed.

Clickjacking

/login and the main dashboard set X-Frame-Options: DENY and Content-Security-Policy: … frame-ancestors 'none'. Browsers block embedding these pages in <iframe> or <frame> from any origin.

Passkey counter regression rejection

WebAuthn authenticators report an optional monotonic counter. The correct rule implemented here: reject if received_counter < stored_counter. If stored == 0 AND received == 0, accept — this is a legitimate counter-less authenticator (some hardware tokens always report 0). Counter regression (received < stored) is rejected and logged as event_type='passkey_fail' metadata='counter_regression' in auth_events.

Rate limits

All limits are enforced via D1 queries. No Durable Objects are used. The SELECT that checks the limit runs before any INSERT; if the limit is exceeded, no write occurs (prevents write-amplification DoS on the D1 free tier).

Limit Value
Code sends per email, 15-minute burst 3
Code sends per email, 24-hour ceiling 20
Requests per IP, 1-hour window 50
Verify failures per code 5 (per-code verify_attempts column)
Verify failures per email, 10-minute window 5

code_sent and code_verify_fail events both count toward the per-email burst and daily caps. This prevents an attacker who controls an allowlisted email address from using the send endpoint as an unbounded mail-bomb vector.

Cleanup DELETEs for expired login_attempts, login_codes, sessions, and auth_challenges rows run in the scheduled cron handler (every 6 hours), not in request handlers.

Audit log

All login events, failures, passkey enrollments/removals, user admin actions, and bearer break-glass uses are written to the auth_events D1 table. Retention is 90 days; older rows are pruned by the scheduled cron. auth_events is separate from the KV event ring buffer — it is durable, indexed by email and timestamp, and survives the KV ring rolling over.

Bearer break-glass logging

Every state-changing POST authenticated via Authorization: Bearer (break-glass path) is logged to auth_events with event_type='bearer_break_glass', ip_address, and user_agent. Operators can review these entries in the Events tab or via direct D1 query.

To rotate ADMIN_TOKEN: delete the Secret in the Cloudflare dashboard and redeploy. The old token is invalid the moment the new Secret takes effect. All sessions created via the bearer path remain valid until their 12-hour TTL or explicit revocation.

Email compromise

An attacker who controls an operator's email address can sign in via the email-code flow — the same threat model as any system that uses email for password reset. Mitigation: enroll a passkey and remove your email from the user allowlist (DELETE /users/:email). The passkey then becomes the sole interactive login path. The bearer break-glass remains available for recovery.

Admin token threat model

Admin token is generated in Workers Builds CI and stored as a Cloudflare Secret (encrypted at rest). The token appears once in the build log for that deployment. Build logs are visible to dashboard users with Workers Platform Admin or equivalent on your Cloudflare account — the same trust boundary that already permits Secret reads. There is no broader exposure.

To rotate: delete the ADMIN_TOKEN Secret (Cloudflare dashboard → Workers & Pages → your worker → Settings → Variables and Secrets) and redeploy. The build log for the new deploy will show the new token once.

*.workers.dev enumerability. Cloudflare issues TLS certificates for all *.workers.dev subdomains. These certificates are logged to public Certificate Transparency logs (e.g., crt.sh), making your worker subdomain enumerable by anyone watching CT logs. This is a Cloudflare platform property, not a bug in this tool. There is no public bootstrap endpoint in this codebase — the admin token is never accessible via an HTTP response.

There aren’t any published security advisories