Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Two options, and the tradeoff is real:

<!-- CDN: no server needed to try it, pinned and integrity-checked. -->
<script
src="https://cdn.jsdelivr.net/npm/@webdecoy/fcaptcha-client@1.34.0/dist/fcaptcha.min.js"
src="https://cdn.jsdelivr.net/npm/@webdecoy/fcaptcha-client@1.34.1/dist/fcaptcha.min.js"
integrity="sha384-…"
crossorigin="anonymous"></script>
```
Expand Down
4 changes: 2 additions & 2 deletions charts/fcaptcha/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 26 additions & 3 deletions client/fcaptcha.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions server-node/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion server-node/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion server-python/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
125 changes: 125 additions & 0 deletions test/browser/tests/challenge-refresh.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `<!doctype html><html><body>
<div id="captcha"></div>
<script src="http://localhost:3000/fcaptcha.js"></script>
<script>
FCaptcha.configure({serverUrl: 'http://localhost:3000'});
FCaptcha.render('captcha', {siteKey: 'challenge-refresh-test'});
window.__widget = function () {
var F = window.FCaptcha;
return F.widgets.get(Array.from(F.widgets.keys())[0]);
};
</script>
</body></html>`,
})
);
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();
});
});
Loading