Skip to content

feat(altcha): optional ALTCHA bot-protection module - #59

Merged
hendrikebbers merged 2 commits into
mainfrom
feat/altcha-module
Oct 28, 2026
Merged

hendrikebbers merged 2 commits into
mainfrom
feat/altcha-module

Conversation

@hendrikebbers

Copy link
Copy Markdown
Contributor

What

New optional feature module spring-services-altcha (spec 023). It protects endpoints that accept requests without authentication, such as contact, sign-up or lead forms, against automated submissions.

It uses ALTCHA, a self-hosted proof of work:

  • no external service, no cookies, no tracking
  • so no consent banner and no data-processing agreement are needed

It uses org.altcha:altcha 2.1.1 (MIT, protocol v2, PBKDF2/SHA-256).

The module comes from the Support & Care Check server, where it protects the public check form. It was written there in the shape of a spring-services module so it could move here.

How an application uses it

openelements.altcha.enabled=true
openelements.altcha.hmac-key=${ALTCHA_HMAC_KEY}   # >= 32 characters
@PostMapping("/api/my-form")
ResponseEntity<Void> submit(@RequestParam(name = "altcha", required = false) String altcha, …) {
    altchaService.requireVerified(altcha);
    …
}

The widget (altcha 3.x) fetches challenges from GET /api/altcha/challenge. That path is configurable.

Design decisions

  • Opt-in, not self-activating. The module needs a signing secret. If it activated itself whenever it is on the classpath, every spring-services-all consumer without a secret would fail to start. When the module is enabled without a key of at least 32 characters, start-up fails with a clear message.
  • Own SecurityFilterChain (@Order(0)), whose matcher is exactly the challenge path:
    • it permits only GET and denies every other method there
    • it is needed because the default JWT chain would demand a token from the visitor of a public form
    • it is only registered when an HttpSecurity bean exists
    • the application's own form endpoint is not opened by the module
  • Uniform error response: 400 {"code","message"}.
    • The codes are stable: altcha-missing, -invalid, -expired, -reused.
    • The message is English or German, depending on Accept-Language. Unknown languages get English, independent of the JVM default.
    • The handler can be replaced by declaring a bean of the same type.
  • Replay protection comes through a replaceable ReplayStore. The default keeps used challenges in memory, which is only correct for a single instance. A shared store is listed in docs/TODO.md.
  • Every bean is @ConditionalOnMissingBean and defined on the auto-configuration itself, as in StorageAutoConfiguration.

Changes

  • New module spring-services-altcha, added to the reactor, the BOM and spring-services-all. altcha.version is in the reactor pom.
  • Spec 023: design.md and behaviors.md, plus an index row.
  • README section, a section in docs/releases/upgrade-to-1.5.md written as a consumer prompt, and a TODO entry for the shared replay store.

Tests

19 tests in the module:

  • auto-configuration:
    • inert by default
    • activates when enabled
    • fails without a key
    • backs off for an application ReplayStore
    • no web part outside a web application
  • verifier and service:
    • a solution is accepted once
    • missing, invalid, foreign-key and expired payloads are rejected
    • codes and localised messages
  • a MockMvc test behind Spring Security, using a stand-in default chain that demands authentication everywhere:
    • the challenge is public and served with no-store
    • POST on the challenge path and other paths stay closed
    • form round trip
    • 400 body in German and English

./mvnw verify passes for the whole reactor. That includes spring-services-all's aggregate starter test, which confirms the module stays inert there.

🤖 Generated with Claude Code

New feature module spring-services-altcha (spec 023): self-hosted ALTCHA
proof of work (protocol v2) for endpoints without authentication, with no
external service and no cookies.

- AltchaService.requireVerified(payload) for the application's endpoint
- failed checks answered with 400 {"code","message"}, stable codes and an
  English/German message from the request locale
- own SecurityFilterChain that permits only GET on the challenge path
- opt-in via openelements.altcha.enabled=true; start-up fails without a
  hmac-key of at least 32 characters
- replaceable ReplayStore, in-memory by default

Moved from the Support & Care Check server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: hendrikebbers <9443847+hendrikebbers@users.noreply.github.com>
@hendrikebbers
hendrikebbers merged commit d6cb484 into main Oct 28, 2026
1 check passed
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.

2 participants