diff --git a/INSTALLATION.md b/INSTALLATION.md index 97db90c..4facea4 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -274,8 +274,9 @@ FCaptcha state is process-local by default. In the Go server, `REDIS_URL` now shares PoW challenges, token replay protection, Siteverify idempotency, rate limits, suspicion, fingerprint cardinality, and site-key rotation guards. Challenge and token claims are atomic. Go may run multiple replicas when Redis -is configured. Node currently shares only PoW challenge/claim state, and Python -does not yet use Redis; both must remain single-instance. +is configured. Node now shares the same security-state classes and may also run +multiple replicas with Redis. Python does not yet use Redis and must remain +single-instance. Run: @@ -425,9 +426,8 @@ server { ``` **Important:** Multiple Go instances require `REDIS_URL`; without it, all state -is process-local. Node shares only PoW state when Redis is configured; its other -stores remain local. Python remains entirely process-local. Both must run as one -instance. +is process-local. Node also supports multiple instances with Redis. Python +remains entirely process-local and must run as one instance. --- @@ -439,7 +439,7 @@ instance. |----------|----------|---------|-------------| | `FCAPTCHA_SECRET` | Yes | - | Secret key for signing tokens (min 16 chars) | | `FCAPTCHA_INSECURE_DEV_MODE` | No | off | Explicitly use the public development signing key for local-only development. Never expose a server with this enabled | -| `REDIS_URL` | No | - | Go only: Redis URL for shared security state. Required for multiple replicas; Node and Python ignore it and must stay single-instance. Configuration and runtime failures are fail-closed | +| `REDIS_URL` | No | - | Redis URL for shared security state. Go and Node support multiple replicas; Python does not yet use it. Configuration and runtime failures are fail-closed | | `FCAPTCHA_VERIFY_SECRET` | No | `FCAPTCHA_SECRET` | Credential your backend sends as `secret` when verifying a token. Split it from the signing key so a leaked verify credential cannot also mint tokens | | `FCAPTCHA_LEGACY_UNAUTH_VERIFY` | No | off | Restore the pre-1.22.0 behaviour where token verification accepted any caller. Migration cover for one release — see [Upgrading to 1.22.0](#upgrading-to-1220) | | `FCAPTCHA_ALLOWED_HOSTNAMES` | No | (any) | Comma-separated hostnames permitted to mint tokens, matched against the request `Origin` (then `Referer`) | diff --git a/README.md b/README.md index f45e728..c3cab3c 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,8 @@ rotation guards across replicas. Challenge and token consumption are atomic. It refuses to start if configured Redis is unavailable and fails closed if it becomes unavailable later. -With `REDIS_URL`, the Go server can run multiple replicas. Node currently shares -PoW challenge/claim state only; its other stores remain local, so it must remain -single-instance. Python does not yet use Redis and must also remain -single-instance. +With `REDIS_URL`, the Go and Node servers can run multiple replicas. Python does +not yet use Redis and must remain single-instance. Kubernetes: @@ -783,7 +781,7 @@ Set `action` (and optionally `cdata`) when you request the token — | `FCAPTCHA_LEGACY_UNAUTH_VERIFY` | Restore the pre-1.22.0 behaviour where token verification accepted any caller. One release of migration cover; **do not leave it on** | off | | `FCAPTCHA_ALLOWED_HOSTNAMES` | Comma-separated hostnames permitted to mint tokens, matched against the request's `Origin` (then `Referer`). Unset accepts any origin. A request with no derivable origin (native app, server-side call) is always allowed — an attacker who can forge an `Origin` would just forge a listed one | (any) | | `PORT` | Server port | 3000 | -| `REDIS_URL` | Share PoW challenges, token replay protection, Siteverify idempotency, site-key state and suspicion history across replicas. **Go server only** — Node and Python ignore it and must stay single-instance. The server refuses to start if a configured Redis is unreachable, and fails closed if it drops out | (unset, process-local state) | +| `REDIS_URL` | Share security state across replicas. Go and Node share all security stores and support multiple replicas. Python does not yet use Redis. Configured Redis failures are fail-closed | (unset, process-local state) | | `TRUSTED_PROXIES` | Comma-separated CIDRs/IPs of peers allowed to set `X-Forwarded-For`, `X-Real-IP` and the TLS-fingerprint headers. `*` trusts every peer, `none` trusts none. See [Trusted proxies](#trusted-proxies) | loopback + private ranges | | `FCAPTCHA_SITE_KEYS` | Comma-separated allowlist of accepted site keys. Unset accepts any key (zero-config self-hosting); unlisted keys are folded into a shared overflow bucket rather than allocating their own rate-limit/fingerprint state | (any) | | `FCAPTCHA_MAX_SITE_KEYS_PER_IP` | Distinct site keys one IP may allocate state for before the excess is folded into the overflow bucket. The cap itself is unconditional | 8 | diff --git a/server-node/redis-state.js b/server-node/redis-state.js index ceaa1c9..0b782a7 100644 --- a/server-node/redis-state.js +++ b/server-node/redis-state.js @@ -1,10 +1,13 @@ 'use strict'; +const crypto = require('crypto'); const { createClient } = require('redis'); const PREFIX = 'fcaptcha:v1:'; const POW_TTL_MS = 5 * 60 * 1000; const SPENT_TTL_MS = 10 * 60 * 1000; +const IDEMPOTENCY_TTL_MS = 5 * 60 * 1000; +const DETECTION_TTL_MS = 15 * 60 * 1000; const CLAIM_CHALLENGE = ` if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end @@ -12,6 +15,28 @@ if redis.call('SET', KEYS[2], '1', 'NX', 'PX', ARGV[1]) == false then return -1 redis.call('DEL', KEYS[1]) return 1 `; +const RATE_CHECK = ` +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1]) +local count = redis.call('ZCARD', KEYS[1]) +local added = 0 +if count < tonumber(ARGV[3]) then + redis.call('ZADD', KEYS[1], ARGV[2], ARGV[4]) + count = count + 1 + added = 1 +end +redis.call('PEXPIRE', KEYS[1], ARGV[5]) +return {count, added} +`; +const SITEKEY_CLAIM = ` +if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[3]) + return 1 +end +if redis.call('SCARD', KEYS[1]) >= tonumber(ARGV[2]) then return 0 end +redis.call('SADD', KEYS[1], ARGV[1]) +redis.call('PEXPIRE', KEYS[1], ARGV[3]) +return 1 +`; class RedisState { constructor(url, client = null) { @@ -28,6 +53,11 @@ class RedisState { return `${PREFIX}pow:challenge:${id}`; } + opaqueKey(kind, value) { + const digest = crypto.createHash('sha256').update(value).digest('hex'); + return `${PREFIX}${kind}:${digest}`; + } + async putChallenge(challenge) { const ttl = challenge.expiresAt - Date.now(); if (ttl <= 0) throw new Error('challenge already expired'); @@ -55,6 +85,89 @@ class RedisState { reason: result === -1 ? 'solution_already_used' : 'challenge_not_found' }; } + + async claimToken(signature) { + return Boolean(await this.client.set( + `${PREFIX}token:spent:${signature}`, + '1', + { NX: true, PX: SPENT_TTL_MS } + )); + } + + idempotencyKey(idempotencyKey, token) { + const tokenHash = crypto.createHash('sha256').update(token).digest('hex').slice(0, 32); + const composite = `${idempotencyKey}:${tokenHash}`; + const opaque = crypto.createHash('sha256').update(composite).digest('hex'); + return `${PREFIX}siteverify:idempotency:${opaque}`; + } + + async getIdempotency(idempotencyKey, token) { + if (!idempotencyKey) return null; + const payload = await this.client.get(this.idempotencyKey(idempotencyKey, token)); + return payload ? JSON.parse(payload) : null; + } + + async setIdempotency(idempotencyKey, token, response) { + if (!idempotencyKey) return; + await this.client.set( + this.idempotencyKey(idempotencyKey, token), + JSON.stringify(response), + { PX: IDEMPOTENCY_TTL_MS } + ); + } + + async rateCheck(key, windowSeconds, maxRequests) { + const now = Date.now(); + const result = await this.client.eval(RATE_CHECK, { + keys: [this.opaqueKey('rate', key)], + arguments: [ + String(now - windowSeconds * 1000), String(now), String(maxRequests), + `${now}:${crypto.randomBytes(8).toString('hex')}`, String(windowSeconds * 1000 + 1000) + ] + }); + return [Number(result[1]) === 0, Number(result[0])]; + } + + async recordSuspicion(siteKey, ip) { + const now = Date.now(); + const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`); + await this.client.multi() + .zRemRangeByScore(key, '-inf', now - DETECTION_TTL_MS) + .zAdd(key, { score: now, value: `${now}:${crypto.randomBytes(8).toString('hex')}` }) + .zRemRangeByRank(key, 0, -17) + .pExpire(key, DETECTION_TTL_MS) + .exec(); + } + + async suspicionCount(siteKey, ip) { + const key = this.opaqueKey('suspicion', `${siteKey}|${ip}`); + await this.client.zRemRangeByScore(key, '-inf', Date.now() - DETECTION_TTL_MS); + return Number(await this.client.zCard(key)); + } + + async recordFingerprint(fingerprint, ip, siteKey) { + const fpKey = this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`); + const ipKey = this.opaqueKey('fingerprint:fps', ip); + await this.client.multi() + .sAdd(fpKey, this.opaqueKey('value:ip', ip)).pExpire(fpKey, DETECTION_TTL_MS) + .sAdd(ipKey, this.opaqueKey('value:fp', fingerprint)).pExpire(ipKey, DETECTION_TTL_MS) + .exec(); + } + + async ipFingerprintCount(ip) { + return Number(await this.client.sCard(this.opaqueKey('fingerprint:fps', ip))); + } + + async fingerprintIpCount(fingerprint, siteKey) { + return Number(await this.client.sCard(this.opaqueKey('fingerprint:ips', `${siteKey}|${fingerprint}`))); + } + + async claimSiteKey(siteKey, ip, maxPerIp) { + return Number(await this.client.eval(SITEKEY_CLAIM, { + keys: [this.opaqueKey('sitekeys', ip)], + arguments: [this.opaqueKey('value:sitekey', siteKey), String(maxPerIp), String(60 * 60 * 1000)] + })) === 1; + } } -module.exports = { RedisState, PREFIX, POW_TTL_MS, SPENT_TTL_MS }; +module.exports = { RedisState, PREFIX, POW_TTL_MS, SPENT_TTL_MS, IDEMPOTENCY_TTL_MS }; diff --git a/server-node/redis-state.test.js b/server-node/redis-state.test.js index d6f90f1..2367d6a 100644 --- a/server-node/redis-state.test.js +++ b/server-node/redis-state.test.js @@ -10,7 +10,11 @@ class FakeRedis { } on() {} async ping() { return 'PONG'; } - async set(key, value) { this.values.set(key, value); return 'OK'; } + async set(key, value, options = {}) { + if (options.NX && this.values.has(key)) return null; + this.values.set(key, value); + return 'OK'; + } async get(key) { return this.values.get(key) || null; } async eval(_script, { keys }) { if (!this.values.has(keys[0])) return 0; @@ -42,6 +46,14 @@ class FakeRedis { await issuer.claimChallenge(challenge.id, 'challenge-1:7'), { claimed: false, reason: 'challenge_not_found' } ); + + assert.strictEqual(await verifier.claimToken('token-signature'), true); + assert.strictEqual(await issuer.claimToken('token-signature'), false); + + const response = { success: true, hostname: 'example.com', 'error-codes': [] }; + await issuer.setIdempotency('retry-key', 'token', response); + assert.deepStrictEqual(await verifier.getIdempotency('retry-key', 'token'), response); + assert.strictEqual(await verifier.getIdempotency('retry-key', 'different-token'), null); console.log('redis shared-state tests passed'); })().catch((err) => { console.error(err); diff --git a/server-node/server.js b/server-node/server.js index 7ef803a..df9ac10 100644 --- a/server-node/server.js +++ b/server-node/server.js @@ -23,7 +23,7 @@ const { sanitizeAction, sanitizeCdata, secretMatches, - siteverify + siteverifyAsync } = require('./siteverify'); const app = express(); @@ -61,7 +61,12 @@ const LEGACY_UNAUTH_VERIFY = /^(1|true|yes|on)$/i.test( const ALLOWED_HOSTNAMES = HostnameAllowlist.fromEnv(); // Lets a caller retry a validation that timed out without burning the token. -const IDEMPOTENCY = new IdempotencyStore(); +const IDEMPOTENCY = SHARED_STATE + ? { + get: (key, token) => SHARED_STATE.getIdempotency(key, token), + set: (key, token, response) => SHARED_STATE.setIdempotency(key, token, response) + } + : new IdempotencyStore(); const PORT = process.env.PORT || 3000; const TRUSTED_JA4_HEADERS = detection.getTrustedJA4HeaderNames(); @@ -76,9 +81,32 @@ const PROXY_TRUST = ProxyTrust.fromEnv(); // limits.js — the cap is unconditional; FCAPTCHA_SITE_KEYS adds an allowlist. const SITE_KEYS = SiteKeyGuard.fromEnv(); +async function normalizeSiteKey(siteKey, ip) { + const key = siteKey || 'default'; + if (SITE_KEYS.allowlist && !SITE_KEYS.allowlist.has(key)) return ' overflow'; + if (!SHARED_STATE || !ip) return SITE_KEYS.normalize(key, ip); + try { + return await SHARED_STATE.claimSiteKey(key, ip, SITE_KEYS.maxPerIp) ? key : ' overflow'; + } catch (_) { + return ' overflow'; + } +} + // Recent strong verdicts per source, used to price the next challenge that // source asks for. Bounded and short-lived; see suspicion.js. -const suspicionLedger = new SuspicionLedger(); +const localSuspicionLedger = new SuspicionLedger(); +const suspicionLedger = { + async record(siteKey, ip, score) { + if (!SHARED_STATE) return localSuspicionLedger.record(siteKey, ip, score); + if (score < 0.8 || !ip) return; + try { await SHARED_STATE.recordSuspicion(siteKey, ip); } catch (_) { /* fail closed on reads */ } + }, + async count(siteKey, ip) { + if (!SHARED_STATE) return localSuspicionLedger.count(siteKey, ip); + if (!ip) return 0; + try { return await SHARED_STATE.suspicionCount(siteKey, ip); } catch (_) { return 16; } + } +}; // Express's own `trust proxy` would re-derive req.ip from the same headers on // its own terms; IP resolution goes through PROXY_TRUST.clientIP exclusively. @@ -279,7 +307,11 @@ const powChallengeStore = { const rateLimiter = { requests: new BoundedMap(), - check(key, windowSeconds = 60, maxRequests = 10) { + async check(key, windowSeconds = 60, maxRequests = 10) { + if (SHARED_STATE) { + try { return await SHARED_STATE.rateCheck(key, windowSeconds, maxRequests); } + catch (_) { return [true, maxRequests]; } + } const now = Date.now(); const cutoff = now - (windowSeconds * 1000); @@ -301,7 +333,11 @@ const fingerprintStore = { fingerprints: new BoundedMap(), ipFingerprints: new BoundedMap(), - record(fp, ip, siteKey) { + async record(fp, ip, siteKey) { + if (SHARED_STATE) { + try { await SHARED_STATE.recordFingerprint(fp, ip, siteKey); } catch (_) { /* reads fail closed */ } + return; + } const key = `${siteKey}:${fp}`; if (!this.fingerprints.has(key)) { @@ -317,11 +353,17 @@ const fingerprintStore = { this.ipFingerprints.get(ip).add(fp); }, - getIpFpCount(ip) { + async getIpFpCount(ip) { + if (SHARED_STATE) { + try { return await SHARED_STATE.ipFingerprintCount(ip); } catch (_) { return 100; } + } return this.ipFingerprints.get(ip)?.size || 0; }, - getFpIpCount(fp, siteKey) { + async getFpIpCount(fp, siteKey) { + if (SHARED_STATE) { + try { return await SHARED_STATE.fingerprintIpCount(fp, siteKey); } catch (_) { return 100; } + } const key = `${siteKey}:${fp}`; return this.fingerprints.get(key)?.ips.size || 0; } @@ -376,7 +418,7 @@ const { } = require('./engine'); // Stateful detectors stay here: they read the module-level stores below. -function detectFingerprint(signals, ip, siteKey) { +async function detectFingerprint(signals, ip, siteKey) { const detections = []; const env = signals.environmental || {}; const automation = env.automationFlags || {}; @@ -390,10 +432,10 @@ function detectFingerprint(signals, ip, siteKey) { ]; const fp = crypto.createHash('sha256').update(components.join('|')).digest('hex').slice(0, 16); - fingerprintStore.record(fp, ip, siteKey); + await fingerprintStore.record(fp, ip, siteKey); // IP fingerprint count - const ipFpCount = fingerprintStore.getIpFpCount(ip); + const ipFpCount = await fingerprintStore.getIpFpCount(ip); if (ipFpCount > 5) { detections.push({ category: 'fingerprint', score: 0.6, confidence: 0.6, @@ -402,7 +444,7 @@ function detectFingerprint(signals, ip, siteKey) { } // Fingerprint IP count - const fpIpCount = fingerprintStore.getFpIpCount(fp, siteKey); + const fpIpCount = await fingerprintStore.getFpIpCount(fp, siteKey); if (fpIpCount > 10) { detections.push({ category: 'fingerprint', score: 0.5, confidence: 0.5, @@ -422,11 +464,11 @@ function detectFingerprint(signals, ip, siteKey) { return detections; } -function detectRateAbuse(ip, siteKey) { +async function detectRateAbuse(ip, siteKey) { const detections = []; const key = `${siteKey}:${ip}`; - const [exceeded, count] = rateLimiter.check(key, 60, 10); + const [exceeded, count] = await rateLimiter.check(key, 60, 10); if (exceeded) { detections.push({ category: 'rate_limit', score: 0.8, confidence: 0.9, @@ -471,7 +513,7 @@ function generateToken(ip, siteKey, score, binding = {}) { return Buffer.from(JSON.stringify(data)).toString('base64url'); } -function verifyToken(token, ip = null) { +async function verifyToken(token, ip = null) { try { const decoded = JSON.parse(Buffer.from(token, 'base64url').toString()); @@ -490,11 +532,6 @@ function verifyToken(token, ip = null) { return { valid: false, reason: 'invalid_signature' }; } - // Check for token replay (single-use tokens) - if (tokenStore.isUsed(sig)) { - return { valid: false, reason: 'token_already_used' }; - } - // Verify IP matches (if provided) if (ip) { const expectedIpHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8); @@ -503,8 +540,15 @@ function verifyToken(token, ip = null) { } } - // Mark token as used (prevents replay) - tokenStore.markUsed(sig); + let claimed; + try { + claimed = SHARED_STATE + ? await SHARED_STATE.claimToken(sig) + : tokenStore.markUsed(sig); + } catch (_) { + return { valid: false, reason: 'state_unavailable' }; + } + if (!claimed) return { valid: false, reason: 'token_already_used' }; // hostname/action/cdata default to '' so a token minted before they existed // still verifies and reports the same shape. The signature covers whatever @@ -520,8 +564,8 @@ function verifyToken(token, ip = null) { action: decoded.action || '', cdata: decoded.cdata || '' }; - } catch (e) { - return { valid: false, reason: e.message }; + } catch (_) { + return { valid: false, reason: 'invalid_token' }; } } @@ -683,10 +727,10 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja ...detectInputForensics(signals), ...detectTouchAuthenticity(signals, userAgent), ...detectSensorEntropy(signals, userAgent), - ...detectTouchKinematics(signals), - ...detectFingerprint(signals, ip, siteKey), - ...detectRateAbuse(ip, siteKey) + ...detectTouchKinematics(signals) ); + detections.push(...await detectFingerprint(signals, ip, siteKey)); + detections.push(...await detectRateAbuse(ip, siteKey)); // Add IP reputation check (async but we'll use sync version for simplicity) if (detection.isDatacenterIP(ip)) { @@ -788,7 +832,7 @@ async function runVerification(signals, ip, siteKey, userAgent, headers = {}, ja // Feed the ledger so the next challenge this source asks for is priced on // what it just did. - suspicionLedger.record(siteKey, ip, finalScore); + await suspicionLedger.record(siteKey, ip, finalScore); return { success, @@ -830,7 +874,7 @@ app.post('/api/verify', async (req, res) => { const { siteKey: rawSiteKey, signals, powSolution, signalsJson, powTiming, action, cdata } = req.body; const ip = PROXY_TRUST.clientIP(req); // Bound the state an unvalidated siteKey can allocate (limits.js). - const siteKey = SITE_KEYS.normalize(rawSiteKey, ip); + const siteKey = await normalizeSiteKey(rawSiteKey, ip); const userAgent = req.headers['user-agent'] || ''; // Only honoured from a trusted proxy: a client that can state its own TLS // fingerprint would just claim a stock Chrome one. @@ -851,7 +895,7 @@ app.post('/api/verify', async (req, res) => { app.post('/api/score', async (req, res) => { const { siteKey: rawSiteKey, signals, action, cdata, powSolution, signalsJson, powTiming } = req.body; const ip = PROXY_TRUST.clientIP(req); - const siteKey = SITE_KEYS.normalize(rawSiteKey, ip); + const siteKey = await normalizeSiteKey(rawSiteKey, ip); const userAgent = req.headers['user-agent'] || ''; const ja3Hash = PROXY_TRUST.trustedHeader(req, 'x-ja3-hash') || null; @@ -882,7 +926,7 @@ app.post('/api/score', async (req, res) => { }); }); -app.post('/api/token/verify', (req, res) => { +app.post('/api/token/verify', async (req, res) => { const { token, secret, remoteip } = req.body; // The secret gate. This endpoint is the boundary between "a browser finished a @@ -901,7 +945,7 @@ app.post('/api/token/verify', (req, res) => { // This request comes from the integrating backend, not from the visitor who // received the token. Bind only when that trusted backend explicitly supplies // the visitor address; using the caller socket here compares unrelated hosts. - res.json(verifyToken(token, typeof remoteip === 'string' && remoteip ? remoteip : null)); + res.json(await verifyToken(token, typeof remoteip === 'string' && remoteip ? remoteip : null)); }); // Turnstile / reCAPTCHA / hCaptcha drop-in compatibility. @@ -910,9 +954,9 @@ app.post('/api/token/verify', (req, res) => { // plugins we want to be usable against FCaptcha: pointing an existing // integration at this server should be a base-URL change and nothing else. // See siteverify.js for the adapter itself. -function siteverifyHandler(req, res) { +async function siteverifyHandler(req, res) { res.json( - siteverify({ + await siteverifyAsync({ body: req.body, // Bind to this server's token store, so replay state is shared with the // native endpoint rather than kept in a parallel universe. @@ -931,14 +975,14 @@ app.post('/siteverify', siteverifyHandler); // PoW Challenge endpoint - client fetches this on page load app.get('/api/pow/challenge', async (req, res) => { const ip = PROXY_TRUST.clientIP(req); - const siteKey = SITE_KEYS.normalize(req.query.siteKey, ip); + const siteKey = await normalizeSiteKey(req.query.siteKey, ip); // Cost scaling. See suspicion.js for why the escalation lands almost // entirely on minAgeMs rather than on difficulty. const rateKey = `pow:${siteKey}:${ip}`; - const [exceeded, count] = rateLimiter.check(rateKey, 60, 20); + const [exceeded, count] = await rateLimiter.check(rateKey, 60, 20); const cost = computeChallengeCost( - suspicionLedger.count(siteKey, ip), + await suspicionLedger.count(siteKey, ip), detection.isDatacenterIP(ip), count, exceeded diff --git a/server-node/siteverify.js b/server-node/siteverify.js index f013ef6..dca5394 100644 --- a/server-node/siteverify.js +++ b/server-node/siteverify.js @@ -314,6 +314,44 @@ function siteverify({ return out; } +// Async form used by Redis-backed servers. Kept separate so the public +// synchronous adapter remains backward compatible for library callers. +async function siteverifyAsync({ + body, + verifyToken, + expectedSecret, + idempotencyStore, + requireSecret = true, +}) { + const { secret, response, remoteip, idempotencyKey } = readParams(body); + if (requireSecret) { + if (!secret) return failure(ERROR_CODES.MISSING_SECRET); + if (!secretMatches(secret, expectedSecret)) return failure(ERROR_CODES.INVALID_SECRET); + } + if (!response) return failure(ERROR_CODES.MISSING_RESPONSE); + + try { + const cached = idempotencyStore && await idempotencyStore.get(idempotencyKey, response); + if (cached) return cached; + const result = await verifyToken(response, remoteip || null); + const out = result && result.valid + ? { + success: true, + challenge_ts: new Date((result.timestamp || 0) * 1000).toISOString(), + hostname: result.hostname || '', + action: result.action || '', + cdata: result.cdata || '', + 'error-codes': [], + score: typeof result.score === 'number' ? result.score : null, + } + : failure(reasonToErrorCode(result && result.reason)); + if (idempotencyStore) await idempotencyStore.set(idempotencyKey, response, out); + return out; + } catch { + return failure(ERROR_CODES.INTERNAL_ERROR); + } +} + module.exports = { ERROR_CODES, HostnameAllowlist, @@ -325,6 +363,7 @@ module.exports = { sanitizeCdata, secretMatches, siteverify, + siteverifyAsync, MAX_ACTION_LENGTH, MAX_CDATA_LENGTH, IDEMPOTENCY_TTL_SECONDS,