Skip to content

Add configurable captcha to the subscribe form - #491

Open
tomahock wants to merge 3 commits into
cachethq:mainfrom
tomahock:feat/subscribe-captcha
Open

tomahock wants to merge 3 commits into
cachethq:mainfrom
tomahock:feat/subscribe-captcha

Conversation

@tomahock

Copy link
Copy Markdown

Problem

The public subscribe form (POST /subscribe) is only protected by throttle:6,1. Every submission for an unverified address queues a new verification email, so the form can be used to send emails to addresses that never asked for them.

Solution

A new Settings → Manage Captcha page lets admins choose a provider for the subscribe form:

Provider Configuration Notes
None (default) – Current behaviour, nothing changes on upgrade
ALTCHA complexity Self-hosted proof of work compatible with the ALTCHA protocol. No external service, cookies or keys
Cloudflare Turnstile site key + secret key
hCaptcha site key + secret key
Google reCAPTCHA v2 site key + secret key

Implementation

  • Cachet\Captcha\Captcha interface (enabled(), render(), verify()), resolved by CaptchaManager (an Illuminate\Support\Manager) from the new CaptchaSettings (group captcha, secret_key encrypted). Adding a provider means adding a driver.
  • Hosted providers share SiteVerifyCaptcha, which posts to each provider's siteverify endpoint. Network or HTTP errors fail closed and are logged.
  • ALTCHA is implemented without new dependencies. GET /subscribe/captcha (throttled) issues HMAC-signed challenges that expire after 5 minutes, and each solution can only be used once (cache). A small inline script solves the challenge with crypto.subtle and enables the submit button.
  • <x-cachet::captcha /> renders the active widget inside the form, along with its validation error.
  • No new composer/npm dependencies and no compiled asset changes.
  • Translations for en and pt_BR. Other locales fall back to en.

Tests

  • SubscribeCaptchaTest: the default stays unchanged; ALTCHA happy path plus missing, malformed, wrong-number, unsigned, expired and replayed solutions; each hosted provider with Http::fake for success, refusal, missing token, connection error and HTTP 500; providers stay disabled until both keys are set.
  • ManageCaptchaTest: page renders, fields show and hide per provider, keys are required for hosted providers, and the secret key is stored encrypted.

The full suite, Pint and PHPStan pass locally.

The public subscribe form was only rate limited, so it could be used to
send verification emails to addresses that never asked for them.

A new Captcha settings page lets the provider be chosen from the
dashboard: none (default), ALTCHA (self-hosted proof of work, no keys or
external service), Cloudflare Turnstile, hCaptcha or Google reCAPTCHA v2.
Providers sit behind a Captcha interface resolved by CaptchaManager, so
more can be added as drivers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T17:41:34.695964Z 8e892df PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8e892df6e6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const widget = document.currentScript.previousElementSibling
const form = widget.closest('form')
const input = widget.querySelector('input[name="altcha"]')
const buttons = form ? form.querySelectorAll('[type="submit"]') : []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Query the submit button after the form is parsed

When this inline script runs during normal HTML parsing, the <button> later in status-page/subscribe.blade.php has not been added to the DOM yet, so this static NodeList is empty. setState() therefore never disables it; a visitor who submits while the proof-of-work is still running sends an empty altcha value and gets a validation failure. Initialize after parsing or manage the state via the repository's Alpine x-data pattern instead.

AGENTS.md reference: AGENTS.md:L82-L85

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7610694. The widget is now an Alpine component (resources/js/altcha.js, loaded only when an ALTCHA widget is on the page) and its init() runs after the form is parsed. It disables the submit buttons until the challenge is solved, and it also blocks submit events in the meantime, so a forced submit can't send an empty payload. The inline script is gone.

}

const sha256 = async (text) => {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Provide hashing when Web Crypto is unavailable

On status pages served over plain HTTP from a non-loopback host, Web Crypto's subtle API is unavailable because it is restricted to secure contexts. sha256() then rejects every run, the widget enters error, and with ALTCHA enabled the server rejects every subscription, even though this package can be installed into consumer hosts whose routing and configuration it does not control. Add a compatible fallback or prevent ALTCHA from being selected where a secure context is not guaranteed.

AGENTS.md reference: AGENTS.md:L100-L107

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7610694. sha256() now falls back to a plain JavaScript SHA-256 when crypto.subtle is unavailable, so ALTCHA also works on pages served over plain HTTP. I checked the fallback against Node's crypto on 2,000+ inputs, including padding edge cases and multibyte text. It hashes 50k candidates in about 0.3 s. I also ran the form end to end in Chromium over plain HTTP from a non-loopback host.

Comment thread src/Captcha/Drivers/AltchaCaptcha.php Outdated

public function verify(Request $request): bool
{
$payload = json_decode(base64_decode((string) $request->input('altcha'), true) ?: '', true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject array ALTCHA payloads before casting

With ALTCHA enabled, a request such as altcha[]=x makes $request->input('altcha') an array. Casting it to string raises E_WARNING, which Laravel converts to ErrorException, so this public endpoint returns a 500 instead of the expected captcha validation error. Check is_string before decoding, as the hosted-driver path already does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7610694. Non-string or empty altcha input is rejected before decoding, and a test now covers altcha[]=x.

tomahock and others added 2 commits October 26, 2026 10:26
- Move the widget to an Alpine component (resources/js/altcha.js), loaded
  only when the page has an ALTCHA widget. It runs after the form is
  parsed, so the submit button is actually disabled (and submits blocked)
  until the challenge is solved.
- Fall back to a plain JavaScript SHA-256 when Web Crypto is unavailable,
  so ALTCHA also works on pages served over plain HTTP.
- Reject non-string ALTCHA payloads instead of failing with a 500.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@tomahock

Copy link
Copy Markdown
Author

Addressed the review feedback in 7610694. bc4eac0 recompiles public/build; feel free to drop it if you'd rather compile assets yourselves.

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