diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ae920d..3e42b60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,20 @@ the project uses [Semantic Versioning](https://semver.org/) — with the caveat that pre-2.0 it has used minor bumps for behaviour changes that a stricter reading would call major. Read the **Breaking** entries rather than the number. +## [1.34.1] — 2026-08-30 + +### Fixed +- **A form left open past the challenge lifetime can be solved again.** The + widget fetched a proof-of-work challenge when it loaded and never replaced + it, so a form filled in slowly — or a tab returned to later — submitted a + solution to a challenge the server had already expired. The server refused + the proof and withheld the token, while scoring the visitor as human and + recommending `allow`; the checkbox reported "Verification failed". The + failure path also kept the spent challenge, so every retry failed the same + way until a reload. The widget now replaces a challenge at or near its expiry + before solving, and lines up a fresh one after a failed attempt. Browser + regression tests cover all three cases. + ## [1.34.0] — 2026-08-23 ### Added diff --git a/README.md b/README.md index 750b8ec..5c2ff37 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ Two options, and the tradeoff is real: ``` diff --git a/charts/fcaptcha/Chart.yaml b/charts/fcaptcha/Chart.yaml index eb17e35..32a9e69 100644 --- a/charts/fcaptcha/Chart.yaml +++ b/charts/fcaptcha/Chart.yaml @@ -4,8 +4,8 @@ description: Open source CAPTCHA with proof of work, behavioural biometrics, and type: application # Chart version moves independently of the app: a fix to a template is a chart # release, not an FCaptcha release. -version: 0.1.13 -appVersion: "1.34.0" +version: 0.1.14 +appVersion: "1.34.1" home: https://github.com/WebDecoy/FCaptcha sources: - https://github.com/WebDecoy/FCaptcha diff --git a/client/fcaptcha.js b/client/fcaptcha.js index 9279ef4..e58e392 100644 --- a/client/fcaptcha.js +++ b/client/fcaptcha.js @@ -14,7 +14,7 @@ // Keep in sync with server-node/package.json when cutting a release; this // string ships to integrators. server-node/version.test.js enforces it // across every file that carries the version, and lists them. - version: '1.34.0', + version: '1.34.1', widgets: new Map(), serverUrl: null, // Site-wide language default. Per-widget `lang` still wins; leaving both @@ -116,6 +116,13 @@ const TOKEN_LIFETIME_MS = 300000; const TOKEN_EXPIRY_MARGIN_MS = 15000; + // A PoW challenge is fetched when the widget loads and is only good until the + // server's expiry. Anything left sitting on the page longer than that — a + // contact form filled in slowly, a tab restored from the background — would + // otherwise submit a solution to a challenge that no longer exists and be + // refused, so a challenge this close to expiring is replaced before solving. + const CHALLENGE_REFRESH_MARGIN_MS = 15000; + /** * Picks the best available translation for a requested tag. * @@ -2505,6 +2512,15 @@ } } + // Whether the held challenge is at or near its expiry. A challenge with no + // expiry stated is treated as good, since there is nothing to compare. + challengeExpired() { + const expiresAt = this.challenge && this.challenge.expiresAt; + if (!expiresAt) return false; + + return Date.now() >= expiresAt - CHALLENGE_REFRESH_MARGIN_MS; + } + _generateLocalChallenge() { const id = Math.random().toString(36).substr(2) + Date.now().toString(36); this.challenge = { @@ -2555,8 +2571,8 @@ async _solve(siteKey, signalsHash) { if (this.solving) return this.solvePromise; - // Fetch challenge if not already fetched - if (!this.challenge) { + // Fetch a challenge if there isn't one, or if the one we have is spent. + if (!this.challenge || this.challengeExpired()) { await this.fetchChallenge(siteKey); } @@ -3165,6 +3181,13 @@ if (this.options.errorCallback) this.options.errorCallback(reason); + // The challenge that produced this attempt is spent either way — the + // server has seen its solution, or it refused one it no longer holds — so + // clicking again with the same one fails again. Line up a fresh challenge + // now, while the label counts down, so the retry has one to work with. + this.powManager.reset(); + this._fetchChallenge(); + setTimeout(() => { this.checkbox.classList.remove('failed'); this.label.textContent = this.strings.label; diff --git a/client/package-lock.json b/client/package-lock.json index 32f1ba9..ccaa3e5 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "@webdecoy/fcaptcha-client", - "version": "1.34.0", + "version": "1.34.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@webdecoy/fcaptcha-client", - "version": "1.34.0", + "version": "1.34.1", "license": "MIT", "devDependencies": { "esbuild": "^0.24.0" diff --git a/client/package.json b/client/package.json index 9fa22cf..c18e342 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/fcaptcha-client", - "version": "1.34.0", + "version": "1.34.1", "description": "Browser widget for FCaptcha \u2014 proof of work, behavioural signals, and AI-agent detection", "main": "fcaptcha.js", "browser": "fcaptcha.js", diff --git a/server-node/package-lock.json b/server-node/package-lock.json index c739331..e2980bb 100644 --- a/server-node/package-lock.json +++ b/server-node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@webdecoy/fcaptcha", - "version": "1.34.0", + "version": "1.34.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@webdecoy/fcaptcha", - "version": "1.34.0", + "version": "1.34.1", "license": "MIT", "dependencies": { "cors": "^2.8.5", diff --git a/server-node/package.json b/server-node/package.json index 643c229..1fedbd9 100644 --- a/server-node/package.json +++ b/server-node/package.json @@ -1,6 +1,6 @@ { "name": "@webdecoy/fcaptcha", - "version": "1.34.0", + "version": "1.34.1", "description": "Open source CAPTCHA with PoW, bot detection, and Vision AI protection", "main": "index.js", "exports": { diff --git a/server-python/server.py b/server-python/server.py index 4bf1b96..c180a40 100644 --- a/server-python/server.py +++ b/server-python/server.py @@ -43,7 +43,7 @@ # Keep in sync with server-node/package.json on release. Enforced by # server-node/version.test.js, which lists every file carrying the version. -app = FastAPI(title="FCaptcha", version="1.34.0") +app = FastAPI(title="FCaptcha", version="1.34.1") MAX_REQUEST_BODY_BYTES = 64 * 1024 diff --git a/test/browser/tests/challenge-refresh.spec.ts b/test/browser/tests/challenge-refresh.spec.ts new file mode 100644 index 0000000..cddf260 --- /dev/null +++ b/test/browser/tests/challenge-refresh.spec.ts @@ -0,0 +1,125 @@ +import { test, expect, Page } from '@playwright/test'; + +test.setTimeout(60_000); + +/** + * Challenge refresh. + * + * The widget fetches a PoW challenge when it loads, and the server only holds + * that challenge for five minutes. Nothing re-fetched it: `_solve` asked for a + * challenge only when it had none at all, so a page open longer than the expiry + * — a contact form filled in slowly, a tab restored hours later — submitted a + * solution to a challenge the server no longer had. The server refused it, the + * widget showed "Verification failed", and because the failure path left the + * spent challenge in place, every retry failed the same way until a reload. + * + * Neither half is a scoring decision: the visitor is a person and scores like + * one. The server said so — `recommendation: allow` on the very request whose + * token it withheld. + */ + +async function loadWidget(page: Page) { + await page.route('http://localhost:3000/__challenge_refresh__', (route) => + route.fulfill({ + status: 200, + contentType: 'text/html; charset=utf-8', + body: `
+ + + + `, + }) + ); + await page.goto('http://localhost:3000/__challenge_refresh__'); + await page.waitForSelector('.fcaptcha-checkbox'); + // The first challenge is fetched in the background when the widget renders. + await page.waitForFunction(() => !!(window as any).__widget().powManager.challenge); +} + +const challengeId = (page: Page) => + page.evaluate(() => (window as any).__widget().powManager.challenge?.challengeId ?? null); + +/** Every /api/pow/challenge the page asks for, in order. */ +function recordChallengeFetches(page: Page): string[] { + const fetched: string[] = []; + page.on('request', (req) => { + if (req.url().includes('/api/pow/challenge')) fetched.push(req.url()); + }); + return fetched; +} + +test.describe('PoW challenge refresh', () => { + test('replaces a challenge that expired while the page sat open', async ({ page }) => { + const fetched = recordChallengeFetches(page); + await loadWidget(page); + + const first = await challengeId(page); + expect(fetched.length, 'one challenge fetched on load').toBe(1); + + // What a page left open past the server's five-minute window looks like + // from the client: the challenge it holds is no longer one the server has. + await page.evaluate(() => { + (window as any).__widget().powManager.challenge.expiresAt = Date.now() - 60_000; + }); + expect(await page.evaluate(() => (window as any).__widget().powManager.challengeExpired())).toBe(true); + + const solved = await page.evaluate(async () => { + const solution = await (window as any) + .__widget() + .powManager.solveWithSignalsHash('challenge-refresh-test', 'deadbeef'); + return solution.challengeId; + }); + + expect(fetched.length, 'the stale challenge must be replaced').toBe(2); + expect(solved, 'the solution must be for the new challenge').not.toBe(first); + expect(solved).toBe(await challengeId(page)); + expect( + await page.evaluate(() => !!(window as any).__widget().powManager.challenge.local), + 'the replacement must come from the server, not the local fallback' + ).toBe(false); + }); + + test('keeps a challenge that still has expiry to spare', async ({ page }) => { + const fetched = recordChallengeFetches(page); + await loadWidget(page); + + const first = await challengeId(page); + expect(await page.evaluate(() => (window as any).__widget().powManager.challengeExpired())).toBe(false); + + await page.evaluate(() => + (window as any).__widget().powManager.solveWithSignalsHash('challenge-refresh-test', 'deadbeef') + ); + + expect(fetched.length, 'a live challenge must not be thrown away').toBe(1); + expect(await challengeId(page)).toBe(first); + }); + + test('a failed attempt leaves a fresh challenge ready for the retry', async ({ page }) => { + await loadWidget(page); + const first = await challengeId(page); + + // The no-message path is the one the servers actually take: they answer + // success:false with no reason attached. + await page.evaluate(() => (window as any).__widget()._showFailure(undefined)); + + await page.waitForFunction( + (before) => { + const challenge = (window as any).__widget().powManager.challenge; + return !!challenge && challenge.challengeId !== before; + }, + first + ); + + expect( + await page.evaluate(() => (window as any).__widget().powManager.solution), + 'the spent solution must not survive into the retry' + ).toBeNull(); + }); +});