Skip to content

Commit ca8329b

Browse files
committed
Release v1.2.0 - ALTCHA proof-of-work bot protection
Replaces per-IP rate limiting (which broke for organisations with many users behind one NAT IP) with a per-browser SHA-256 proof-of-work challenge solved in a Web Worker. Cost is paid per browser, the challenge is invisible to legitimate users, no third-party service or external JS is involved, and difficulty adapts to per-form submit rate. The challenge signature is bound to the form's file ID so a challenge issued for one form cannot be reused on another. Single-use replay protection via Nextcloud's distributed cache (Redis) with APCu fallback for single-server installs. The anonymous submit rate limit becomes a generous safety net (raised from 100/hour to 25 000/hour) so that even a 10 000-employee training evaluation finishing within one hour goes through, while pathological abuse is still bounded if the cache backend goes down. Closes #76. Also includes the markdown-rendering fix for public form descriptions from #63 (h1/lists/links/etc. now render correctly with explicit typography styles that survive Nextcloud's CSS resets). Also fixes #77: authenticated submit failed for restricted folders. When a public form had `require_login: true` and lived in a Group Folder or Team Folder the respondent wasn't a member of, the authenticated submit path called formService->load() — a user-context loader that requires folder ACL — and failed with "Form not found". submitAuthenticated() now uses loadPublic() (the same admin-bypass loader anonymous submissions use); auth gates (token, share gate, allowed_users/groups, require_login) still run before it, so there is no change in authorization scope, only the read handle for the form definition.
1 parent 3c1b61f commit ca8329b

13 files changed

Lines changed: 372 additions & 11 deletions

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22

33
All notable changes to FormVox will be documented in this file.
44

5-
## [1.1.6] - 2026-05-05
5+
## [1.2.0] - 2026-05-05
6+
7+
### Added
8+
- **Bot protection that works behind NAT** — Public form submissions are now protected by an ALTCHA-style proof-of-work challenge solved in the user's browser, replacing per-IP rate limiting as the primary anti-bot defense. Cost is paid per browser, so an organisation with hundreds of users behind a single NAT IP all submit without throttling. The challenge is invisible to legitimate users (~50–150 ms of work in a Web Worker), self-hosted (no third-party service, no external JS, no API keys, GDPR-clean), and adapts difficulty to the per-form submit rate so attackers pay more under load. The signature is bound to the form's file ID so a challenge issued for one form cannot be reused on another. Single-use replay protection via Nextcloud's distributed cache (Redis) with APCu fallback for single-server installs. ([#76](https://github.com/nextcloud/formvox/issues/76))
9+
10+
### Changed
11+
- **Anonymous submit rate limit raised from 100/hour to 25 000/hour** — With ALTCHA now the primary defense, the per-IP limit becomes a wide safety net rather than the front line. The new ceiling comfortably accommodates large-organisation peaks (think 10 000 employees filling in a training evaluation in one hour) while still bounding pathological abuse if the cache backend goes down.
612

713
### Fixed
814
- **Form description rendered as plain text on the public form** — The form description on the public response page now renders as markdown instead of literal text with the raw `#`/`*` characters and collapsed newlines. Headings, lists, links, code, and blockquotes in the form description, section descriptions, and the in-editor markdown preview all render with proper visual styling. ([#63](https://github.com/nextcloud/formvox/issues/63))
15+
- **"Form not found" / "Access forbidden" for logged-in respondents on restricted folders** — When a public form had `require login` enabled and was stored in a Group Folder or Team Folder the respondent was not a member of, the submission failed because the authenticated submit path used a user-context file load. Authenticated respondents now use the same admin-bypass loader as anonymous submissions, so the share link plus token (and any `allowed_users`/`allowed_groups` rules) are the only gate — no folder ACL needed. ([#77](https://github.com/nextcloud/formvox/issues/77))
916

1017
## [1.1.5] - 2026-05-04
1118

appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
* Expense declarations
3636
* Data collection with privacy requirements
3737
]]></description>
38-
<version>1.1.6</version>
38+
<version>1.2.0</version>
3939
<licence>agpl</licence>
4040
<author mail="info@voxcommons.com">Sam Ditmeijer</author>
4141
<author mail="info@voxcommons.com">Rik Dekker</author>

appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
['name' => 'public#showForm', 'url' => '/public/{fileId}/{token}', 'verb' => 'GET'],
1414
['name' => 'public#authenticate', 'url' => '/public/{fileId}/{token}', 'verb' => 'POST'],
1515
['name' => 'public#submit', 'url' => '/public/{fileId}/{token}/submit', 'verb' => 'POST'],
16+
['name' => 'public#challenge', 'url' => '/public/{fileId}/{token}/challenge', 'verb' => 'GET'],
1617
['name' => 'public#uploadFile', 'url' => '/public/{fileId}/{token}/upload', 'verb' => 'POST'],
1718

1819
// Embed routes (frameable version for iframes)

js/da0651ca5ec18ccbe5fd.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

js/formvox-public.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/Controller/PublicController.php

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use OCA\FormVox\Service\FormService;
2626
use OCA\FormVox\Service\ResponseService;
2727
use OCA\FormVox\Service\BrandingService;
28+
use OCA\FormVox\Service\ChallengeService;
2829

2930
class PublicController extends Controller
3031
{
@@ -35,6 +36,7 @@ class PublicController extends Controller
3536
private FormService $formService;
3637
private ResponseService $responseService;
3738
private BrandingService $brandingService;
39+
private ChallengeService $challengeService;
3840
private IInitialState $initialState;
3941

4042
public function __construct(
@@ -46,6 +48,7 @@ public function __construct(
4648
FormService $formService,
4749
ResponseService $responseService,
4850
BrandingService $brandingService,
51+
ChallengeService $challengeService,
4952
IInitialState $initialState
5053
) {
5154
parent::__construct(Application::APP_ID, $request);
@@ -56,6 +59,7 @@ public function __construct(
5659
$this->formService = $formService;
5760
$this->responseService = $responseService;
5861
$this->brandingService = $brandingService;
62+
$this->challengeService = $challengeService;
5963
$this->initialState = $initialState;
6064
}
6165

@@ -337,15 +341,46 @@ private function getEffectiveBranding(array $form): array
337341
return $this->brandingService->getBranding();
338342
}
339343

344+
/**
345+
* Issue an ALTCHA proof-of-work challenge for a public form.
346+
*
347+
* Replaces per-IP rate limiting on submit (NAT-unfriendly) with a
348+
* per-browser PoW token. Difficulty adapts to the per-form submit rate.
349+
*/
350+
#[PublicPage]
351+
#[NoCSRFRequired]
352+
public function challenge(int $fileId, string $token): DataResponse
353+
{
354+
// $token is required for route binding (so /public/{fileId}/challenge
355+
// doesn't collide with /public/{fileId}/{token}); we don't validate
356+
// it here — verification happens at submit time.
357+
unset($token);
358+
return new DataResponse($this->challengeService->issue($fileId));
359+
}
360+
340361
/**
341362
* Submit response (anonymous or authenticated based on form settings)
342363
*/
343364
#[PublicPage]
344365
#[NoCSRFRequired]
345-
#[AnonRateLimit(limit: 100, period: 3600)]
366+
#[AnonRateLimit(limit: 25000, period: 3600)]
346367
#[BruteForceProtection(action: 'formvox_submit')]
347-
public function submit(int $fileId, string $token, array $answers): DataResponse
368+
public function submit(int $fileId, string $token, array $answers, ?array $altcha = null): DataResponse
348369
{
370+
// Anti-bot: require a valid ALTCHA proof-of-work token before doing
371+
// any work. Cost is per-browser, so 500 users behind one NAT IP each
372+
// pay their own (tiny) cost — see issue #76. The AnonRateLimit above
373+
// remains as a generous safety net for pathological cases.
374+
if ($this->userSession->getUser() === null
375+
&& !$this->challengeService->verify($altcha, $fileId)) {
376+
$response = new DataResponse(
377+
['error' => 'Challenge verification failed. Please refresh the page.'],
378+
Http::STATUS_TOO_MANY_REQUESTS
379+
);
380+
$response->throttle();
381+
return $response;
382+
}
383+
349384
try {
350385
$form = $this->loadAndValidateForm($fileId, $token);
351386

lib/Service/ChallengeService.php

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OCA\FormVox\Service;
6+
7+
use OCP\IConfig;
8+
use OCP\ICacheFactory;
9+
use OCP\Security\ISecureRandom;
10+
11+
/**
12+
* ALTCHA-compatible proof-of-work challenge issuer/verifier.
13+
*
14+
* The browser solves SHA-256(salt + N) for some N in [0, maxnumber] such that
15+
* the digest matches the expected `challenge`. Difficulty = expected work,
16+
* tuned by maxnumber. Server-side verification recomputes the digest, checks
17+
* the HMAC signature (so the client can't forge a challenge), and enforces
18+
* single-use via cache to prevent replay.
19+
*
20+
* Replaces per-IP rate limiting on public form submission — cost is per
21+
* browser, NAT-friendly. See issue #76.
22+
*/
23+
class ChallengeService
24+
{
25+
/** Single-use challenge expiry, in seconds. */
26+
private const EXPIRY_SECONDS = 600;
27+
28+
/** Difficulty rungs (max number to search). Higher = more work. */
29+
private const DIFFICULTY_LOW = 50_000;
30+
private const DIFFICULTY_MID = 250_000;
31+
private const DIFFICULTY_HIGH = 1_000_000;
32+
33+
/** Submit-rate thresholds (per hour, per form) that bump difficulty. */
34+
private const RATE_THRESHOLD_MID = 1_500;
35+
private const RATE_THRESHOLD_HIGH = 10_000;
36+
37+
public function __construct(
38+
private IConfig $config,
39+
private ICacheFactory $cacheFactory,
40+
private ISecureRandom $secureRandom,
41+
) {
42+
}
43+
44+
/**
45+
* Cache for replay-protection and rate tracking.
46+
*
47+
* Prefers a distributed backend (Redis/Memcached) for multi-server
48+
* setups; falls back to the local backend (APCu) which works fine for
49+
* the ~95% of Nextcloud installs that run on a single server. If
50+
* neither is available we return null and the caller treats that as
51+
* "challenge accepted, no replay tracking" — better than refusing all
52+
* submissions on a misconfigured instance.
53+
*/
54+
private function cache(): ?\OCP\ICache
55+
{
56+
if ($this->cacheFactory->isAvailable()) {
57+
return $this->cacheFactory->createDistributed('formvox_altcha_');
58+
}
59+
if ($this->cacheFactory->isLocalCacheAvailable()) {
60+
return $this->cacheFactory->createLocal('formvox_altcha_');
61+
}
62+
return null;
63+
}
64+
65+
/**
66+
* Issue a new challenge for a given form.
67+
*
68+
* Difficulty adapts to the per-form submit rate so a popular form does
69+
* not penalise other forms on the same instance.
70+
*
71+
* @return array{algorithm:string,challenge:string,salt:string,signature:string,maxnumber:int}
72+
*/
73+
public function issue(int $fileId): array
74+
{
75+
$maxnumber = $this->currentDifficulty($fileId);
76+
$secretNumber = random_int(0, $maxnumber);
77+
$salt = bin2hex(random_bytes(12));
78+
$challenge = hash('sha256', $salt . $secretNumber);
79+
// Bind the signature to the fileId so a challenge issued for a
80+
// low-difficulty form can't be reused on a high-difficulty form
81+
// (which would defeat adaptive difficulty escalation).
82+
$signature = hash_hmac('sha256', $fileId . ':' . $challenge, $this->hmacKey());
83+
84+
return [
85+
'algorithm' => 'SHA-256',
86+
'challenge' => $challenge,
87+
'salt' => $salt,
88+
'signature' => $signature,
89+
'maxnumber' => $maxnumber,
90+
];
91+
}
92+
93+
/**
94+
* Verify a solved challenge payload from the client.
95+
*
96+
* Expected payload shape (ALTCHA standard):
97+
* { algorithm, challenge, salt, signature, number }
98+
*/
99+
public function verify(?array $payload, int $fileId): bool
100+
{
101+
if (!\is_array($payload)) {
102+
return false;
103+
}
104+
foreach (['algorithm', 'challenge', 'salt', 'signature', 'number'] as $k) {
105+
if (!isset($payload[$k])) {
106+
return false;
107+
}
108+
}
109+
if ($payload['algorithm'] !== 'SHA-256') {
110+
return false;
111+
}
112+
113+
// Signature is bound to fileId — see issue(). A challenge issued for
114+
// form A will not validate when submitted to form B.
115+
$expectedSig = hash_hmac('sha256', $fileId . ':' . (string)$payload['challenge'], $this->hmacKey());
116+
if (!hash_equals($expectedSig, (string)$payload['signature'])) {
117+
return false;
118+
}
119+
120+
$recomputed = hash('sha256', $payload['salt'] . $payload['number']);
121+
if (!hash_equals($recomputed, (string)$payload['challenge'])) {
122+
return false;
123+
}
124+
125+
// Single-use: reject if we've seen this challenge before.
126+
// If no cache backend is available we accept the challenge (HMAC +
127+
// SHA-256 still verified above) but lose replay protection — better
128+
// than blocking all submissions on a misconfigured instance.
129+
$cache = $this->cache();
130+
if ($cache !== null) {
131+
$key = "used_{$fileId}_" . substr($payload['challenge'], 0, 32);
132+
if ($cache->get($key)) {
133+
return false;
134+
}
135+
$cache->set($key, 1, self::EXPIRY_SECONDS);
136+
}
137+
138+
// Track per-form submit rate so difficulty adapts on the next issue().
139+
$this->bumpSubmitRate($fileId);
140+
141+
return true;
142+
}
143+
144+
private function currentDifficulty(int $fileId): int
145+
{
146+
$rate = $this->currentSubmitRate($fileId);
147+
if ($rate >= self::RATE_THRESHOLD_HIGH) {
148+
return self::DIFFICULTY_HIGH;
149+
}
150+
if ($rate >= self::RATE_THRESHOLD_MID) {
151+
return self::DIFFICULTY_MID;
152+
}
153+
return self::DIFFICULTY_LOW;
154+
}
155+
156+
private function currentSubmitRate(int $fileId): int
157+
{
158+
$cache = $this->cache();
159+
if ($cache === null) {
160+
return 0;
161+
}
162+
return (int)($cache->get($this->rateKey($fileId)) ?? 0);
163+
}
164+
165+
private function bumpSubmitRate(int $fileId): void
166+
{
167+
$cache = $this->cache();
168+
if ($cache === null) {
169+
return;
170+
}
171+
$cache->set($this->rateKey($fileId), $this->currentSubmitRate($fileId) + 1, 3600);
172+
}
173+
174+
private function rateKey(int $fileId): string
175+
{
176+
return "submit_rate_{$fileId}_" . $this->currentHourBucket();
177+
}
178+
179+
private function currentHourBucket(): string
180+
{
181+
return (string)((int)floor(time() / 3600));
182+
}
183+
184+
private function hmacKey(): string
185+
{
186+
$secret = $this->config->getSystemValueString('secret', '');
187+
if ($secret === '') {
188+
$secret = $this->config->getAppValue('formvox', 'altcha_hmac_key', '');
189+
if ($secret === '') {
190+
$secret = $this->secureRandom->generate(64, ISecureRandom::CHAR_ALPHANUMERIC);
191+
$this->config->setAppValue('formvox', 'altcha_hmac_key', $secret);
192+
}
193+
}
194+
return 'formvox_altcha:' . $secret;
195+
}
196+
}

lib/Service/ResponseService.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,11 @@ public function submitAnonymousWithForm(int $fileId, array $form, array $answers
9494
*/
9595
public function submitAuthenticated(int $fileId, array $answers, string $userId, string $displayName): array
9696
{
97-
$form = $this->formService->load($fileId);
97+
// Use the admin-bypass loader: respondents are authenticated for
98+
// identity (the response is attributed to $userId) but they don't
99+
// need file/folder permissions on the form file itself. The share
100+
// link + token already validated their right to submit. (#77)
101+
$form = $this->formService->loadPublic($fileId);
98102

99103
// Check if form accepts responses
100104
$this->validateFormAcceptsResponses($form);

package-lock.json

Lines changed: 9 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "formvox",
3-
"version": "1.1.6",
3+
"version": "1.2.0",
44
"description": "File-based forms and polls for Nextcloud",
55
"scripts": {
66
"build": "webpack",
@@ -16,6 +16,7 @@
1616
"@nextcloud/router": "^3.0.0",
1717
"@nextcloud/vue": "^9.1.0",
1818
"@vue/compiler-sfc": "^3.5.22",
19+
"altcha-lib": "^1.2.0",
1920
"chart.js": "^4.5.1",
2021
"easymde": "^2.21.0",
2122
"markdown-it": "^14.1.1",

0 commit comments

Comments
 (0)