From d06573f575f69e292a5389b01ba4e87f391cee2e Mon Sep 17 00:00:00 2001 From: Kat Suricata Date: Sat, 8 Aug 2026 23:59:03 -0400 Subject: [PATCH] Require the site secret key as a bearer credential on POST /verify The verify endpoint checked only the public site key and an Origin header. Server-to-server callers send no Origin, so the documented escape hatch was allowed_origins "*", and anyone holding the public site key could hammer /verify with candidate proofs: each attempt cost an Equihash verification and filled the replay store and logs. POST /verify now requires "Authorization: Bearer ", the hCaptcha/reCAPTCHA siteverify model. The secret is compared in constant time, and a missing, malformed, or wrong credential gets 401 with a WWW-Authenticate header before any proof verification runs. The Origin check, the CORS preflight route, and the CORS response headers are gone from /verify: the endpoint is server-to-server by construction and behaves identically in the default and Cloudron builds. allowed_origins now guards GET /challenge alone. - hecapte_verify_requests_total vocabulary: origin_not_allowed -> unauthorized; the verification-failure log line drops the origin field - Demo page stops at the solved proof (the browser holds no credential for /verify) and tells the operator the backend verifies it - New router tests pin the credential gate (missing/malformed/wrong bearer, WWW-Authenticate), the absent CORS surface (preflight 405, no CORS response headers), and the metric series; the end-to-end flow test now carries credentials - README updated for the new contract, CHANGELOG 4.0.0 entry added, PRODUCT.md key model refreshed --- CHANGELOG | 3 + PRODUCT.md | 4 +- README.md | 73 ++++++++++----- cmd/server/main.go | 34 +++---- cmd/server/router_test.go | 151 ++++++++++++++++++++++++++++-- internal/observability/metrics.go | 2 +- locales/active.en.toml | 8 +- templates/demo.html | 29 +----- 8 files changed, 216 insertions(+), 88 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index ab3493d..49b8e7f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,7 @@ [4.0.0] +* `POST /verify` now requires authentication. The protected service presents the site's secret key as `Authorization: Bearer ` (the hCaptcha siteverify model). A missing, malformed, or wrong credential gets `401 Unauthorized` with a `WWW-Authenticate` header, and the rejection happens before any proof verification runs — the public site key alone can no longer drive Equihash verification work or fill the replay store. The comparison is constant-time. Behaves identically in the default and Cloudron builds. +* The `Origin` check and the CORS preflight on `/verify` are removed with it: the endpoint is server-to-server by construction, so browsers have no surface left there. `allowed_origins` now guards only `GET /challenge`. The `hecapte_verify_requests_total` result vocabulary loses `origin_not_allowed` and gains `unauthorized`, and the verification-failure log line no longer carries an `origin` field. +* The demo page stops at the solved proof and says that the backend verifies it. The browser no longer holds any credential that `/verify` accepts, so the old end-to-end demo loop cannot work as it was. * Removed the last piece of per-process state: replay protection now lives in a shared store instead of an in-memory `sync.Map`. Three consequences: restarts no longer open a 60-second replay window, rolling deploys and multiple replicas work, and the mode is visible at startup (`Replay protection store: ...`). * The default replay store is the new `used_salts` table in the existing SQLite database (WAL journal mode plus a busy timeout, so co-located instances can share one file; salts are claimed with an atomic insert-or-fail upsert and swept after expiry, every 5 minutes). * An optional Redis store (new `REDIS_URL` env var, `rediss://` supported, `CLOUDRON_REDIS_URL` honored as a fallback) covers instances that run on several hosts. Cloudron packages keep using the SQLite default in `/app/data`, which persists across restarts and updates and is included in backups, so the Redis addon is not needed there. diff --git a/PRODUCT.md b/PRODUCT.md index 194c004..1a5ffdc 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -26,7 +26,7 @@ Traditional CAPTCHAs tax humans (tracking, image labeling, distorted audio) to p - Distributed and deployed two ways: direct self-hosting (build binary, set env vars, reverse proxy) and as a Cloudron app package (Dockerfile, manifest, `start.sh` at repo root). - Server refuses to start without `ADMIN_LOGIN_POW_SECRET`; first run requires a setup flow for admin credentials. - Operators manage site keys and difficulty (Equihash N/K, global and per-site) through a WebAuthn-first admin interface with password fallback. -- Integrators embed a site key in page JavaScript, run the WASM solver (via a Web Worker wrapping global `solveChallenge`), and verify solutions server-to-server against `/verify`. +- Integrators embed a site key in page JavaScript, run the WASM solver (via a Web Worker wrapping global `solveChallenge`), and verify solutions server-to-server against `/verify` (authenticated with the site secret key). - Evaluation path: a developer typically lands on the README, tries the bundled demo page, then reads the API reference. ## Capabilities and Constraints @@ -34,7 +34,7 @@ Traditional CAPTCHAs tax humans (tracking, image labeling, distorted audio) to p - Stateless verification (no server-side challenge session storage); salt-based token cache for replay protection; HMAC-signed challenges verified in constant time. - Per-site origin validation (CORS); challenge rate limiting. - Multi-language infrastructure via go-i18n with Accept-Language detection and embedded `active..toml` locale files. **Live commitment, EN first:** English is the only shipped locale today; the author is not personally driven by i18n but is genuinely committed to making community translation easy and intuitive, so a translation can make the app fully native to its users. Server messages must be pre-localized in Go and passed to templates. -- Two-key model: public `site_key` (embedded in pages, sent to `/challenge` and `/verify`) vs. `secret_key` (server-only); the distinction must never be blurred in UI or docs. +- Two-key model: public `site_key` (embedded in pages, sent to `/challenge` and `/verify`) vs. `secret_key` (backend-only; the bearer credential on `/verify`, never in a page); the distinction must never be blurred in UI or docs. - Undecided/open: no hosted/SaaS offering is claimed; difficulty presets and any future ports/integrations evolve with the README's Ports & Integrations section. ## Brand Commitments diff --git a/README.md b/README.md index 8d26de9..8f3dd62 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ HeCAPTe implements a stateless, privacy-preserving proof-of-work system that use 1. **Challenge Request:** The client requests a challenge from `/challenge?site_key=...` 2. **Challenge Generation:** The server generates a random salt and signs it with difficulty parameters (N, K) and a timestamp 3. **WASM Solver:** The client uses the WebAssembly module to solve the Equihash puzzle computationally -4. **Verification:** The client sends the solved proof to your backend. Your backend sends the proof to `/verify`. The server checks the HMAC signature in constant time. Then it verifies the Equihash proof. The server records the salt in the replay store, so one proof can pass exactly one time. +4. **Verification:** The client sends the solved proof to your backend. Your backend sends the proof to `/verify` with `Authorization: Bearer `. The server checks the HMAC signature in constant time. Then it verifies the Equihash proof. The server records the salt in the replay store, so one proof can pass exactly one time. 5. **Result:** If the solution is valid, the server returns a success status. If the solution is invalid or expired, the server returns an error. ### Site Key and Secret Key @@ -62,16 +62,17 @@ Each site has two keys. The two keys are different values. Do not mix them. | Value | Also called | Who uses it | Where you send it | |---|---|---|---| | `site_key` | Public key | Anyone. You embed it in your page JavaScript. | To `/challenge` and to `/verify`. | -| `secret_key` | Private key | The HeCAPTe server only. | You never send it. HeCAPTe stores it and uses it to check the challenge signature. | +| `secret_key` | Private key | Your backend only. Never in a page or a browser. | As the `Authorization: Bearer` credential to `/verify`. | -Send the public `site_key` to `/verify`. Use the same value that you send to `/challenge`. The secret key stays on the server. +The browser sends the public `site_key` to `/challenge`. Your backend sends the same `site_key` and the `secret_key` to `/verify`. HeCAPTe also uses the secret key to sign challenges and to check their signatures. ### Key Features - **Stateless verification:** No server-side session storage for challenges +- **Authenticated verification:** `POST /verify` requires the secret key of the site as a bearer credential. The endpoint is server-to-server only. - **Replay protection:** A shared store (SQLite or Redis) records each used salt. A proof verifies exactly once, across restarts and across instances. - **Observability:** Optional Prometheus metrics endpoint, a cheap `/healthz` liveness probe, and structured JSON logs with per-site labels -- **CORS support:** Per-site origin validation +- **CORS support:** Per-site origin validation on the public challenge endpoint - **WebAuthn integration:** Passkey-based admin authentication - **Equihash algorithm:** Memory-hard proof-of-work resistant to GPU/ASIC optimization - **Internationalization:** Built-in i18n via go-i18n with Accept-Language detection and embedded TOML translation files @@ -197,7 +198,7 @@ The results vanish when you reload the page. To keep a record, note the medians, - **Production:** Requires TLS 1.3 minimum with HSTS headers - **Response headers:** Both builds set `X-Content-Type-Options: nosniff`, `frame-ancestors 'none'` (CSP), `X-Frame-Options: DENY`, and `Referrer-Policy: same-origin` on every response. The templates use inline scripts, so a full Content-Security-Policy would need per-response nonces and is not set. - **Reverse proxy detection:** The `X-Forwarded-Proto: https` header is only honored when the instance runs behind a trusted, sanitizing reverse proxy (`TRUST_PROXY=1`). Cloudron's `start.sh` sets this value. Direct TLS connections always count as secure. Set `PUBLIC_ORIGIN` (or `--primary-domain`/`CLOUDRON_APP_DOMAIN`) so cookies are marked `Secure` and WebAuthn origins use the operator-declared origin instead of client-controlled headers. In direct deployments never expose the server on plain HTTP and trust its forwarding headers. If a request arrives with `X-Forwarded-Proto` set but `TRUST_PROXY` is unset, the server logs a one-time warning, because this state usually means a misconfigured reverse proxy. -- **CORS:** Configured per-site via `allowed_origins` (comma-separated or `*`) +- **CORS:** Configured per-site via `allowed_origins` (comma-separated or `*`). The check guards `GET /challenge`, the one browser-facing endpoint. `POST /verify` ignores the `Origin` header and answers no preflight. The secret-key credential replaces it. - **Session:** 24-hour duration with secure, HttpOnly, SameSite=Strict cookies - **CSRF Protection:** All admin endpoints require CSRF token (cookie + form/header) - **Password Policy:** Minimum 60 bits of entropy (with `go-password-validator`) @@ -276,7 +277,7 @@ The endpoint is disabled by default. Set the `METRICS_TOKEN` environment variabl | Metric | Type | Labels | Content | |--------|------|--------|---------| | `hecapte_challenge_requests_total` | counter | `site`, `result` | Requests to `GET /challenge`. `result` is `issued`, `unknown_site`, `origin_not_allowed`, or `internal_error`. | -| `hecapte_verify_requests_total` | counter | `site`, `result` | Requests to `POST /verify`. `result` is `ok`, `failed`, `unknown_site`, `origin_not_allowed`, or `bad_request`. | +| `hecapte_verify_requests_total` | counter | `site`, `result` | Requests to `POST /verify`. `result` is `ok`, `failed`, `unknown_site`, `unauthorized`, or `bad_request`. | | `hecapte_verify_failures_total` | counter | `site`, `reason` | Rejected proofs by failure reason. The reason table follows. | | `hecapte_build_info` | gauge | `version` | Constant 1. The `version` label carries the server version. | | `hecapte_uptime_seconds` | gauge | — | Seconds since the server process started. | @@ -363,10 +364,10 @@ The raw site key never appears in a log line. #### The Verification-Failure Line -A rejected proof logs a `WARN` line with the same reason code as the `hecapte_verify_failures_total` metric, plus the origin and the wrapped error: +A rejected proof logs a `WARN` line with the same reason code as the `hecapte_verify_failures_total` metric, plus the wrapped error: ```json -{"time":"2026-08-08T15:07:43Z","level":"WARN","msg":"verification failed","site_name":"Contact form","site_key_hash":"3eb1bd439947eb762998e566ccc2e099c791118b2f40579cc4f7da2b5061b7f9","reason":"replay","origin":"https://app.example.com","request_id":"hecate/YCmM6jjFqB-000008","error":"challenge already used"} +{"time":"2026-08-08T15:07:43Z","level":"WARN","msg":"verification failed","site_name":"Contact form","site_key_hash":"3eb1bd439947eb762998e566ccc2e099c791118b2f40579cc4f7da2b5061b7f9","reason":"replay","request_id":"hecate/YCmM6jjFqB-000008","error":"challenge already used"} ``` A top-failing-sites report no longer needs an awk script. With JSON logs, one `jq` pipeline is sufficient: @@ -429,18 +430,28 @@ Generates a new Equihash challenge for the specified site. The difficulty parame POST /verify ``` -Verifies the solution that a client submits for an Equihash challenge. +Verifies the solution that a client submits for an Equihash challenge. This endpoint is server-to-server. Your backend calls it. A browser never has the credential that the endpoint requires. + +**Authentication:** + +Each request must carry the `Authorization: Bearer ` header. The secret key belongs to the site that `site_key` in the body names. The server compares the value in constant time. + +If the header is missing, if the scheme is not `Bearer`, or if the secret key is wrong, the request gets `401 Unauthorized`. The response carries a `WWW-Authenticate: Bearer realm="verify"` header. The rejection happens before proof verification. An unauthenticated request cannot spend verification CPU and cannot write to the replay store. + +Keep the secret key on your backend. Put it in a configuration file or an environment variable. Never put it in a page, a script, or a repository. **Request Headers:** +- `Authorization: Bearer `: Required. The server-to-server credential of the site. - `Content-Type: application/json` -- `Origin`: A browser sends this header. A server-to-server call does not send it. HeCAPTe checks the `Origin` value against `allowed_origins` for the site. If `allowed_origins` is not `*`, the `Origin` value must match one entry exactly. A call without an `Origin` header is rejected unless `allowed_origins` is `*`. + +The endpoint answers no CORS preflight and sends no CORS headers. The `Origin` header has no effect on this endpoint. The `allowed_origins` list guards `GET /challenge` only. **Request Body:** The body must be 64 KiB or smaller. The server returns `400 Bad Request` for larger bodies and for invalid JSON. -Send the public `site_key`. Use the same value that you send to `/challenge`. Do not send the secret key. HeCAPTe stores the secret key and uses it to check the signature. +Send the public `site_key` in the body. Use the same value that the browser sends to `/challenge`. The `Authorization` header carries the secret key of the same site. A challenge expires 60 seconds after issue. If the solver or the user can take more than 60 seconds, request a new challenge and try again. Do not send an expired challenge. The server accepts each salt once. A replay gets `403 Forbidden`. @@ -472,8 +483,9 @@ A challenge expires 60 seconds after issue. If the solver or the user can take m **Other Error Responses:** -- `400 Bad Request`: Invalid JSON or malformed request -- `403 Forbidden`: Invalid site key, origin, signature, expired challenge, or invalid solution +- `400 Bad Request`: Invalid JSON, or a body over 64 KiB +- `401 Unauthorized`: The `Authorization` header is missing, malformed, or carries a wrong secret key +- `403 Forbidden` (plain text `invalid request`): Unknown site key --- @@ -1003,7 +1015,7 @@ export ADMIN_LOGIN_POW_SECRET="dev-secret-key-change-me" go run ./cmd/server --port 8080 --db hecaptetest.db ``` -Access the demo at [http://localhost:8080/](http://localhost:8080/). This page demonstrates the full challenge-response flow with a Web Worker. +Access the demo at [http://localhost:8080/](http://localhost:8080/). This page demonstrates the challenge and the solve loop with a Web Worker. Verification stays with your backend: the demo has no secret key, and `/verify` requires one. ### Running in Production @@ -1050,7 +1062,7 @@ Screenshots for the Cloudron app store listing are stored in `deploy/cloudron/sc 3. Login: Password authentication with optional Passkey (WebAuthn) 4. Create **Site Keys** and **Secret Keys**: - **Site Key:** Public identifier (embedded in HTML) - - **Secret Key:** Private key (backend verification only) + - **Secret Key:** Private key. Your backend presents it as the bearer credential on `/verify`. 5. **Settings** (`/admin/settings`): Configure the global Equihash difficulty with security presets (Low / Recommended / High). The **Solve-Time Benchmark** on the page times every preset on the current device. Run it on every device class you expect, for example an older phone. 6. **Per-site difficulty:** Edit a site on the dashboard and select a **Difficulty** preset. The site then ignores the global default. 7. **Password** (`/admin/password`): Change admin password (requires current password or passkey reauthentication) @@ -1138,8 +1150,9 @@ async function solveHeCAPTe(worker, siteKey) { } } - // Verify (usually done by your backend) - const response = await fetch('/verify', { + // Send the proof to your own backend. The backend calls /verify with + // the secret key. The browser never holds that key. + const response = await fetch('/submit-form', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1161,15 +1174,24 @@ async function solveHeCAPTe(worker, siteKey) { **4. Backend Verification:** -Send the solved proof to your backend. Do not call `/verify` from the browser. Your backend keeps the flow under your control. +The browser sends the solved proof to your backend. Only your backend calls `/verify`. The call carries the secret key as the bearer credential. ``` Browser --GET /challenge (public site_key)--> HeCAPTe Browser --solved proof--> Your backend -Your backend --POST /verify (public site_key + proof)--> HeCAPTe +Your backend --POST /verify (site_key + proof, Authorization: Bearer secret_key)--> HeCAPTe +``` + +Keep the secret key in the environment or the configuration of your backend. A call from the shell shows the full shape: + +```bash +curl -X POST https://captcha.example.com/verify \ + -H "Authorization: Bearer $HECAPTE_SECRET_KEY" \ + -H "Content-Type: application/json" \ + -d '{"site_key": "...", "data": {...}}' ``` -The secret key stays in the HeCAPTe database. Your backend sends only the public `site_key` and the proof. +A Go backend can embed the verifier and skip the HTTP API. The HeCAPTe packages do the same work in-process: ```go import "hecapte/internal/tokens" @@ -1203,7 +1225,7 @@ func handleSubmission(w http.ResponseWriter, r *http.Request) { } ``` -The example passes the `db` store as the replay store, because `*db.Store` implements `tokens.ReplayStore`. To share replay state across hosts, use `replay.NewRedisStore(redisURL)` and pass its result instead. +The embedded example passes the `db` store as the replay store, because `*db.Store` implements `tokens.ReplayStore`. To share replay state across hosts, use `replay.NewRedisStore(redisURL)` and pass its result instead. ## Ports & Integrations @@ -1221,8 +1243,9 @@ HeCAPTe is designed for embedding into other projects. Below are the known ports | Error | HTTP Status | Cause | Resolution | |-------|-------------|-------|------------| -| `invalid request` | 403 | Invalid site_key or origin not in allowed list | Make sure that the site configuration is correct | -| `bad request` | 400 | Malformed JSON or missing required fields | Verify the request body | +| `invalid request` | 403 | Unknown site_key, or origin not in the allowed list on `/challenge` | Make sure that the site configuration is correct | +| `bad request` | 400 | Malformed JSON or missing required fields | Make sure that the request body is correct | +| `unauthorized` | 401 | The `Authorization` credential on `/verify` is missing, malformed, or wrong | Send `Authorization: Bearer ` from your backend | | `verification failed` | 403 | Invalid signature, expired challenge, or wrong solution | Regenerate the challenge and retry | | `internal error` | 500 | Database or signing failure | Read the server logs | @@ -1245,7 +1268,7 @@ HeCAPTe is designed for embedding into other projects. Below are the known ports **Note:** These error strings are not exported variables. The functions return them as inline `errors.New()` strings. -HeCAPTe returns a vague `403` to the client for a bad site key, a bad origin, or a bad proof. This vagueness is intentional and resists probing. The structured server log records the exact reason. Search for `"msg":"verification failed"` and read the `site_name`, `reason`, and `error` fields. The same data is available as the `hecapte_verify_failures_total` metric when `METRICS_TOKEN` is set. When a valid submission is rejected, read the server logs first. +HeCAPTe returns a vague `403` to the client for a bad site key, for a bad origin on `/challenge`, and for a bad proof on `/verify`. A bad credential on `/verify` gets a `401`. This vagueness is intentional and resists probing. The structured server log records the exact reason. Search for `"msg":"verification failed"` and read the `site_name`, `reason`, and `error` fields. The same data is available as the `hecapte_verify_failures_total` metric when `METRICS_TOKEN` is set. When a valid submission is rejected, read the server logs first. ### Admin Authentication Errors @@ -1347,7 +1370,7 @@ HeCAPTe/ │ │ ├── flags_tls.go # TLS command-line flags (default build) │ │ ├── flags_cloudron.go # Stub TLS flags (cloudron build) │ │ ├── server_test.go # CORS and origin tests -│ │ └── router_test.go # End-to-end tests: /healthz, /metrics gating, verify flow +│ │ └── router_test.go # End-to-end tests: /healthz, /metrics gating, /verify authentication, verify flow │ └── wasm-solver/ # WebAssembly client solver │ └── main.go # WASM entry point ├── deploy/ diff --git a/cmd/server/main.go b/cmd/server/main.go index 2ae5303..aeae353 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,7 @@ package main import ( + "crypto/subtle" "encoding/json" "flag" "log/slog" @@ -338,13 +339,11 @@ func newRouter(d *routerDeps) *chi.Mux { json.NewEncoder(w).Encode(chal) }) - r.Options("/verify", func(w http.ResponseWriter, r *http.Request) { - - w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - w.WriteHeader(http.StatusOK) - }) - + // Verification is server-to-server only: the protected service presents + // the site's secret key as a bearer credential, like hCaptcha's + // siteverify. No browser ever has the secret, so there is intentionally + // no CORS preflight and no Origin check on this route; allowed_origins + // guards the public /challenge route alone. r.Post("/verify", func(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 64*1024) var req struct { @@ -364,25 +363,24 @@ func newRouter(d *routerDeps) *chi.Mux { return } - origin := r.Header.Get("Origin") - if !isOriginAllowed(site.AllowedOrigins, origin) { - d.metrics.IncVerify(site.SiteKey, "origin_not_allowed") - http.Error(w, "invalid request", http.StatusForbidden) + // The credential gate comes before any proof work: without it, anyone + // holding the public site key could burn Equihash verifications and + // fill the replay store. The comparison is constant-time so response + // timing does not leak the secret byte by byte. + presented, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || subtle.ConstantTimeCompare([]byte(presented), []byte(site.SecretKey)) != 1 { + d.metrics.IncVerify(site.SiteKey, "unauthorized") + w.Header().Set("WWW-Authenticate", `Bearer realm="verify"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) return } - if origin != "" { - w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Set("Vary", "Origin") - } - if err := tokens.VerifySolution(d.replay, site.SecretKey, req.Data); err != nil { reason := observability.VerifyFailureReason(err) d.metrics.IncVerify(site.SiteKey, "failed") d.metrics.IncVerifyFailure(site.SiteKey, reason) attrs := append(observability.SiteAttrs(site.Name, req.SiteKey), slog.String("reason", reason), - slog.String("origin", origin), slog.String("request_id", middleware.GetReqID(r.Context())), slog.Any("error", err), ) @@ -439,8 +437,6 @@ func newRouter(d *routerDeps) *chi.Mux { "solvingProgress": hecapte18n.T(loc, "demo.js.solving_progress"), "failedPrefix": hecapte18n.T(loc, "demo.js.failed_prefix"), "solvedIn": hecapte18n.T(loc, "demo.js.solved_in"), - "verified": hecapte18n.T(loc, "demo.js.verified"), - "verificationFailed": hecapte18n.T(loc, "demo.js.verification_failed"), "failedGetChallenge": hecapte18n.T(loc, "demo.js.failed_get_challenge"), }, }, diff --git a/cmd/server/router_test.go b/cmd/server/router_test.go index 8f28279..786d413 100644 --- a/cmd/server/router_test.go +++ b/cmd/server/router_test.go @@ -196,9 +196,11 @@ func TestRouterChallengeVerifyFlowRecordsMetrics(t *testing.T) { } return body } + // Verification is server-to-server: the site's secret key is the bearer + // credential, and the Origin header no longer gates anything. headers := map[string]string{ - "Content-Type": "application/json", - "Origin": "https://app.example.com", + "Content-Type": "application/json", + "Authorization": "Bearer router-test-secret", } // 3. Verify once: OK. @@ -219,13 +221,21 @@ func TestRouterChallengeVerifyFlowRecordsMetrics(t *testing.T) { if rec.Code != http.StatusForbidden { t.Fatalf("GET /challenge unknown site: status = %d, want 403", rec.Code) } + + // 6. The credential gate turns away a missing bearer and a wrong secret + // before any proof work happens. Both count as unauthorized. rec = doRequest(t, rt.mux, http.MethodPost, "/verify", payload(), map[string]string{ "Content-Type": "application/json", - "Origin": "https://evil.example.com", }) - // origins="*" for the test site, so origin never blocks; tamper instead: - if rec.Code != http.StatusForbidden { - t.Fatalf("POST /verify replay: status = %d, want 403", rec.Code) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("POST /verify without bearer: status = %d, want 401", rec.Code) + } + rec = doRequest(t, rt.mux, http.MethodPost, "/verify", payload(), map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer not-the-secret", + }) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("POST /verify wrong bearer: status = %d, want 401", rec.Code) } // 6. Scrape and assert. @@ -240,11 +250,136 @@ func TestRouterChallengeVerifyFlowRecordsMetrics(t *testing.T) { `hecapte_challenge_requests_total{site="` + siteKey + `",result="issued"} 1`, `hecapte_challenge_requests_total{site="",result="unknown_site"} 1`, `hecapte_verify_requests_total{site="` + siteKey + `",result="ok"} 1`, - `hecapte_verify_requests_total{site="` + siteKey + `",result="failed"} 2`, - `hecapte_verify_failures_total{site="` + siteKey + `",reason="replay"} 2`, + `hecapte_verify_requests_total{site="` + siteKey + `",result="failed"} 1`, + `hecapte_verify_requests_total{site="` + siteKey + `",result="unauthorized"} 2`, + `hecapte_verify_failures_total{site="` + siteKey + `",reason="replay"} 1`, } { if !strings.Contains(body, want+"\n") { t.Errorf("metrics missing %q, got:\n%s", want, body) } } } + +// verifyGarbageBody returns a well-formed verify request whose proof is +// nonsense. Verification of it fails, which is exactly what distinguishes +// "reached the verifier" (403 with a JSON failure body) from "rejected at +// the credential gate" (401). +func verifyGarbageBody(t *testing.T, siteKey string) []byte { + t.Helper() + body, err := json.Marshal(map[string]any{ + "site_key": siteKey, + "data": tokens.ChallengeRequest{ + Nonce: "00", + Salt: "00", + Timestamp: 1, + Difficulty: tokens.Difficulty{N: 80, K: 4}, + Signature: "bogus", + Solution: []uint32{1}, + }, + }) + if err != nil { + t.Fatalf("marshal verify body: %v", err) + } + return body +} + +// TestRouterVerifyRequiresSecretKey gates /verify on the site secret: a +// missing, malformed, or wrong bearer credential gets a 401 before any +// proof verification runs, and only the correct secret reaches the verifier. +func TestRouterVerifyRequiresSecretKey(t *testing.T) { + rt, _ := newTestRouter(t, "") + const siteKey = "0123456789abcdef0123456789abcdef" + body := verifyGarbageBody(t, siteKey) + + for _, tc := range []struct { + name string + auth string + want int + }{ + {"missing authorization header", "", http.StatusUnauthorized}, + {"wrong scheme", "Basic cm9vdDpzZWNyZXQ=", http.StatusUnauthorized}, + {"bearer prefix without secret", "Bearer ", http.StatusUnauthorized}, + {"wrong secret", "Bearer not-the-secret", http.StatusUnauthorized}, + {"correct secret with trailing space", "Bearer router-test-secret ", http.StatusUnauthorized}, + {"correct secret reaches the verifier", "Bearer router-test-secret", http.StatusForbidden}, + } { + t.Run(tc.name, func(t *testing.T) { + headers := map[string]string{"Content-Type": "application/json"} + if tc.auth != "" { + headers["Authorization"] = tc.auth + } + rec := doRequest(t, rt.mux, http.MethodPost, "/verify", body, headers) + if rec.Code != tc.want { + t.Fatalf("status = %d, want %d (body %s)", rec.Code, tc.want, rec.Body.String()) + } + if tc.want == http.StatusUnauthorized { + if got := rec.Header().Get("WWW-Authenticate"); got != `Bearer realm="verify"` { + t.Errorf("WWW-Authenticate = %q, want %q", got, `Bearer realm="verify"`) + } + } + if tc.want == http.StatusForbidden { + var out struct { + Status string `json:"status"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.Status != "fail" { + t.Errorf("expected the JSON verification-failure body, got %q", rec.Body.String()) + } + } + }) + } + + // An unknown site key gives the vague 403 even with a well-formed + // bearer: there is no secret to authenticate against, and the answer + // must not turn /verify into a site-key oracle beyond what the public + // /challenge endpoint already reveals. + rec := doRequest(t, rt.mux, http.MethodPost, "/verify", verifyGarbageBody(t, "deadbeef"), + map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer anything", + }) + if rec.Code != http.StatusForbidden { + t.Errorf("POST /verify unknown site with bearer: status = %d, want 403", rec.Code) + } +} + +// TestRouterVerifyHasNoCORSSurface pins the server-to-server shape of +// /verify: browsers get no preflight route and no CORS response headers, +// and the Origin header is never consulted. +func TestRouterVerifyHasNoCORSSurface(t *testing.T) { + rt, _ := newTestRouter(t, "") + const siteKey = "0123456789abcdef0123456789abcdef" + + // A browser preflight finds no handler and is answered 405. + rec := doRequest(t, rt.mux, http.MethodOptions, "/verify", nil, map[string]string{ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "POST", + }) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("OPTIONS /verify: status = %d, want 405", rec.Code) + } + + // A credentialed call with any Origin (or none) must reach the + // verifier identically, and the response must carry no CORS headers. + body := verifyGarbageBody(t, siteKey) + for _, origin := range []string{"", "https://evil.example.com"} { + headers := map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer router-test-secret", + } + if origin != "" { + headers["Origin"] = origin + } + rec := doRequest(t, rt.mux, http.MethodPost, "/verify", body, headers) + // The garbage proof fails verification with 403: that is what proves + // the request passed the credential gate and no origin check remains. + if rec.Code != http.StatusForbidden { + t.Fatalf("POST /verify origin %q: status = %d, want 403 (verification reached)", origin, rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("POST /verify origin %q: unexpected Access-Control-Allow-Origin %q", origin, got) + } + if got := rec.Header().Get("Vary"); got != "" { + t.Errorf("POST /verify origin %q: unexpected Vary header %q", origin, got) + } + } +} diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go index 882673f..0e18ccf 100644 --- a/internal/observability/metrics.go +++ b/internal/observability/metrics.go @@ -41,7 +41,7 @@ const ( // "internal_error"). MetricChallengeRequests = "hecapte_challenge_requests_total" // MetricVerifyRequests counts POST /verify requests, labeled by site - // and result ("ok", "failed", "unknown_site", "origin_not_allowed", + // and result ("ok", "failed", "unknown_site", "unauthorized", // "bad_request"). MetricVerifyRequests = "hecapte_verify_requests_total" // MetricVerifyFailures counts rejected proofs, labeled by site and the diff --git a/locales/active.en.toml b/locales/active.en.toml index fa8ed65..6b9bf8e 100644 --- a/locales/active.en.toml +++ b/locales/active.en.toml @@ -553,13 +553,7 @@ other = "{nonces} nonces tried in {elapsed}s." other = "Failed: " [demo.js.solved_in] -other = "Solved in {duration}ms. Verification in progress." - -[demo.js.verified] -other = "Verified. You are human." - -[demo.js.verification_failed] -other = "Error: " +other = "Solved in {duration}ms. Your backend now verifies this proof with the secret key." [demo.js.failed_get_challenge] other = "The challenge request failed." diff --git a/templates/demo.html b/templates/demo.html index 0c4a8ac..5837b98 100644 --- a/templates/demo.html +++ b/templates/demo.html @@ -120,33 +120,10 @@ } statusEl.innerText = window.I18n.solvedIn.replace('{duration}', duration); + statusEl.className = 'status-subtle success'; - // 3. Verify - const verifyPayload = { - site_key: siteKey, - data: { - nonce: result.nonce, - salt: challenge.salt, - ts: challenge.ts, - diff: challenge.diff, - sig: challenge.sig, - sol: result.solution - } - }; - - fetch("/verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(verifyPayload) - }).then(r => r.json()).then(res => { - if (res.status === "ok") { - statusEl.innerText = window.I18n.verified; - statusEl.className = 'success'; - } else { - statusEl.innerText = window.I18n.verificationFailed + res.error; - statusEl.className = 'warning'; - } - }); + // Verification is server-to-server: the page never holds the + // secret key that /verify requires, so the demo stops here. }; worker.addEventListener('message', resultHandler); -- 2.51.2