Skip to content

Add Turnstile and ALTCHA captcha backends alongside reCAPTCHA - #384

Merged
ramereth merged 2 commits into
masterfrom
captcha-providers
Sep 18, 2026
Merged

ramereth merged 2 commits into
masterfrom
captcha-providers

Conversation

@ramereth

@ramereth ramereth commented Sep 18, 2026 •

Copy link
Copy Markdown
Member

Replaces the Google reCAPTCHA v2 checkbox with a pluggable captcha layer, so the OSL and OpenPOWER Foundation forms can move to Cloudflare Turnstile. It also adds ALTCHA, a self-hosted proof-of-work backend that involves no third party at all. reCAPTCHA keeps working throughout, so each site can migrate on its own schedule.

Spam is getting through the v2 checkbox, which is the variety solver farms target. Google has also moved classic reCAPTCHA keys into Google Cloud, where the free allowance is 10,000 verifications a month per project, and exceeding it without billing attached starts failing requests. The old check had problems of its own. It looked only at success, ignored the hostname the token was solved on, sent the visitor's address under a parameter name neither provider reads, and raised an HTTP 500 instead of rejecting the submission when the field was missing or the network call failed.

The new captcha.py is the place to start reading. It picks a backend from whichever response field the form posted and accepts only backends whose secret is configured, which is what lets one instance serve several sites mid-migration. Then read request_handler.py for the order the checks now run in and for the /altcha challenge endpoint. Turnstile is the recommended replacement because it is free with no request cap and keeps a hosted risk engine behind the widget. ALTCHA is verified locally with no external service, which prices out bulk automation but not a determined operator, so it is the fallback rather than the default.

  • Add captcha.py with Cloudflare Turnstile, ALTCHA and Google reCAPTCHA verifiers, selected per submission by the response field the form posted
  • Serve signed, expiring ALTCHA challenges from a new /altcha endpoint with the CORS headers a widget on another origin needs to fetch them
  • Fail closed on a missing field, a transport error, an unexpected response shape or an unusable payload, all of which previously raised an HTTP 500
  • Refuse a hosted verdict that carries an error code alongside success, which is how reCAPTCHA reports that it has failed open on a spent quota
  • Enforce CAPTCHA_ALLOWED_HOSTNAMES, and a minimum score for reCAPTCHA v3 keys, so a token minted on another site sharing the key pair is refused
  • Run the captcha check before the duplicate check, which records the submission and would otherwise reject the sender's corrected retry as a duplicate
  • Keep captcha fields out of the duplicate hash so duplicate detection works again, and out of the debug log so tokens stay out of the logs
  • Hold an ALTCHA challenge rather than spending it until the RT ticket exists, so a failure after the captcha check does not burn the sender's solved challenge
  • Validate settings at startup and refuse to start on an unusable ALTCHA algorithm, a hostname list naming no hosts, or no provider at all
  • Add TRUSTED_PROXY_COUNT so the captcha provider is told the sender's address rather than HAProxy's
  • Rename error 6 to Invalid Captcha and document the widget, the settings and the test credentials for each provider
  • Document what to put in each field of Cloudflare's Add Widget form, because the hostname and pre-clearance fields are the two that are easy to get wrong

Deploy this before changing either website, because the forms keep working on reCAPTCHA until their widget is swapped.

The website changes and the osl-app Chef recipe are deliberately not in this PR. Both containers need TURNSTILE_SECRET in their data bags and TRUSTED_PROXY_COUNT=1 set before the widgets change, and RECAPTCHA_SECRET should be unset once the last form has migrated, because a submission picks its own provider from those configured.

References:

Spam is getting through the reCAPTCHA v2 checkbox on the OSL and OpenPOWER
forms, and Google has moved classic keys into Google Cloud with a 10,000
verifications a month free tier. The old check also only looked at "success",
passed the visitor IP under the wrong parameter name, and raised a 500 on a
missing field or a network error.

Replace it with a small provider layer so a form chooses its captcha by the
field it posts and each site can migrate on its own schedule.

- Add captcha.py with Cloudflare Turnstile (cf-turnstile-response), ALTCHA
  (altcha) and Google reCAPTCHA (g-recaptcha-response) verifiers
- Accept a provider only when its secret is set, and fail closed on a missing
  field, a transport error, an unexpected response shape or an unusable payload
- Serve signed, expiring ALTCHA challenges from /altcha with CORS headers so
  the widget on another origin can fetch them without any third-party service
- Remember redeemed ALTCHA nonces per worker, capped, so a solved challenge
  cannot simply be sent a second time
- Refuse a hosted verdict carrying an error code alongside success, which is
  how reCAPTCHA reports that it has failed open on a spent quota
- Enforce CAPTCHA_ALLOWED_HOSTNAMES and, for reCAPTCHA v3 keys, a minimum
  score, so a token minted elsewhere with the same key pair is refused
- Check the captcha before the duplicate check, which records the submission
  and would otherwise reject the sender's corrected retry as a duplicate
- Leave captcha fields out of the duplicate hash, so duplicates are caught
  again, and out of the debug log, so tokens stay out of the logs
- Validate settings at startup and refuse to start on an unusable ALTCHA
  algorithm, a hostname list that names no hosts, or no provider at all
- Add TRUSTED_PROXY_COUNT so the verifier is told the sender's address rather
  than HAProxy's, and log which providers a process came up with
- Rename error 6 to "Invalid Captcha" and document the widget, settings and
  test credentials for each provider
- Cover the verifiers, the challenge endpoint, replay, check ordering and each
  fail-closed path, and move the __main__ guard so make tests collects them

https://docs.cloud.google.com/recaptcha/docs/migrate-recaptcha
https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
https://github.com/altcha-org/altcha-lib-py

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lance Albertson <lance@osuosl.org>
Getting a Turnstile site key means filling in a Cloudflare dashboard form whose
fields are not self-explanatory, and two of them are easy to get wrong in ways
that are hard to debug later. Write down the intended value for each one.

- Say which hostname belongs in the widget, the website's rather than
  Formsender's, and that a hostname already covers its subdomains
- Recommend Managed mode, because the other two never escalate for a risky
  visitor, and leave pre-clearance off on a site Cloudflare does not proxy
- Note that CAPTCHA_ALLOWED_HOSTNAMES is matched exactly, unlike the widget's
  own hostname list, so every hostname serving a form has to be listed
- Point the form setup page at the new section for where a site key comes from

https://developers.cloudflare.com/turnstile/additional-configuration/hostname-management/
https://developers.cloudflare.com/turnstile/concepts/pre-clearance-support/

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Lance Albertson <lance@osuosl.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant