From e2917f5f871be188cfb0bec05cd25c2fdcd1d069 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Sat, 17 Oct 2026 12:41:06 +0200 Subject: [PATCH 1/4] Refuse a too-long public answer before saving it Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + .../Controllers/Api/FormSubmitController.php | 5 +-- app/Models/FormSessionResponse.php | 10 ++++- app/Rules/AnswerFitsColumn.php | 25 +++++++++++ tests/Feature/FormSessionTest.php | 43 +++++++++++++++++-- 5 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 app/Rules/AnswerFitsColumn.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 448a15af..df84a784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Lines marked **Upgrade step** need you to act after updating. - Browser Back now leaves a form's Settings page. Before, every tab you opened added a history step, so Back only went through the tabs. A link to a Settings tab still opens that tab. (#287) - A new long text question now shows in your form and in the editor preview, also before you set any options for it. Before, it failed to show. (#286) - A long text question with an empty "Max characters" now has no limit, also when you open it again. Before, the editor showed 500 and saved it back. New long text questions still start at 500. (#291) +- A very long answer is now measured the way the form stores it, so it is refused with a clear message and nothing is saved. Before, a long answer with umlauts or emoji could end in a server error. (#PR) - Ticking "Remember me" on the login page now keeps you logged in after your browser session ends. Before, it was ignored. (#227) - The counter under the text editor shows the length of your text as soon as a text opens. Before, it showed a larger number until your first edit. (#228) - A question or completion page text that holds only an image keeps the image when you save it. Before, it was saved as empty and the image was lost. (#229) diff --git a/app/Http/Controllers/Api/FormSubmitController.php b/app/Http/Controllers/Api/FormSubmitController.php index 584364e5..cd438781 100644 --- a/app/Http/Controllers/Api/FormSubmitController.php +++ b/app/Http/Controllers/Api/FormSubmitController.php @@ -6,7 +6,7 @@ use App\Http\Controllers\Controller; use App\Models\Form; use App\Models\FormSession; -use App\Rules\MaxSize; +use App\Rules\AnswerFitsColumn; use Illuminate\Http\Request; use Knuckles\Scribe\Attributes\BodyParam; use Knuckles\Scribe\Attributes\Group; @@ -41,8 +41,7 @@ public function __invoke(Request $request, Form $form) 'token' => 'required|string', 'is_uploading' => 'boolean', 'payload' => 'array|nullable|max:'.$form->formBlocks()->count(), - // an answer is stored encrypted in a TEXT column (64 KB), encryption almost doubles it - 'payload.*' => [new MaxSize(30000)], + 'payload.*' => [new AnswerFitsColumn], ]); // a preview stores and sends nothing, see CreateFormSessionController diff --git a/app/Models/FormSessionResponse.php b/app/Models/FormSessionResponse.php index 48179652..92f28775 100644 --- a/app/Models/FormSessionResponse.php +++ b/app/Models/FormSessionResponse.php @@ -14,6 +14,9 @@ class FormSessionResponse extends Model { use HasFactory; + // the `value` column is a TEXT + public const VALUE_MAX_BYTES = 65535; + protected $guarded = []; protected $casts = [ @@ -50,7 +53,12 @@ public function formSessionUploads() public function setValueAttribute($new) { - $this->attributes['value'] = config('app.debug') ? json_encode($new) : encrypt($new); + $this->attributes['value'] = self::toStored($new); + } + + public static function toStored($value): string + { + return config('app.debug') ? json_encode($value) : encrypt($value); } public function getValueAttribute() diff --git a/app/Rules/AnswerFitsColumn.php b/app/Rules/AnswerFitsColumn.php new file mode 100644 index 00000000..681e32d1 --- /dev/null +++ b/app/Rules/AnswerFitsColumn.php @@ -0,0 +1,25 @@ + FormSessionResponse::VALUE_MAX_BYTES) { + $fail('This answer is too long. Please shorten it and send it again.'); + + return; + } + } + } +} diff --git a/tests/Feature/FormSessionTest.php b/tests/Feature/FormSessionTest.php index 8071d89c..75f0c02e 100644 --- a/tests/Feature/FormSessionTest.php +++ b/tests/Feature/FormSessionTest.php @@ -338,23 +338,58 @@ 'many short parameters' => [collect(range(1, 600))->mapWithKeys(fn ($i) => ["p$i" => 'x'])->all()], ]); -test('an answer over the size cap is rejected and not stored', function ($template) { +test('an answer at the size cap is stored, one character over is rejected with a clear message', function ($template, $char) { $form = Form::factory()->create(); $form->applyTemplate($template); $session = FormSession::factory()->create(['form_id' => $form->id]); $block = $form->formBlocks[0]; + // the longest answer of this character that still fits the TEXT column once stored + $fits = fn (int $count) => strlen(FormSessionResponse::toStored(str_repeat($char, $count))) <= 65535; + [$low, $high] = [1, 65535]; + while ($low < $high) { + $middle = intdiv($low + $high + 1, 2); + $fits($middle) ? $low = $middle : $high = $middle - 1; + } + $atCap = str_repeat($char, $low); + + expect($fits($low + 1))->toBeFalse(); + $this->json('POST', route('api.public.forms.submit', ['form' => $form->uuid]), [ 'token' => $session->token, - 'payload' => $block->getSubmitPayload(str_repeat('a', 30000)), - ])->assertStatus(422)->assertJsonValidationErrors('payload.'.$block->uuid); + 'payload' => $block->getSubmitPayload($atCap.$char), + ]) + ->assertStatus(422) + ->assertJsonValidationErrors('payload.'.$block->uuid) + ->assertJsonPath('message', 'This answer is too long. Please shorten it and send it again.'); expect(FormSessionResponse::count())->toBe(0); $this->json('POST', route('api.public.forms.submit', ['form' => $form->uuid]), [ 'token' => $session->token, - 'payload' => $block->getSubmitPayload(str_repeat('a', 29000)), + 'payload' => $block->getSubmitPayload($atCap), ])->assertOk(); + + expect(FormSessionResponse::count())->toBe(1); +})->with('templates')->with(['one byte per character' => 'a', 'two bytes per character' => 'ä', 'four bytes per character' => '😀']); + +test('one answer over the size cap saves none of the other answers', function ($template) { + $form = Form::factory()->create(); + $form->applyTemplate($template); + $session = FormSession::factory()->create(['form_id' => $form->id]); + [$first, , , $last] = $form->formBlocks; + + $this->json('POST', route('api.public.forms.submit', ['form' => $form->uuid]), [ + 'token' => $session->token, + 'payload' => [ + ...$first->getSubmitPayload('froggy@mailfrog.com'), + ...$last->getSubmitPayload(str_repeat('a', 65535)), + ], + ]) + ->assertStatus(422) + ->assertJsonPath('message', 'This answer is too long. Please shorten it and send it again.'); + + expect(FormSessionResponse::count())->toBe(0); })->with('templates'); test('more answers than the form has blocks are rejected', function ($template) { From df99601445786fa4b8421a6f9e18d769c099590f Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Sat, 17 Oct 2026 12:41:30 +0200 Subject: [PATCH 2/4] Add PR number to changelog line Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df84a784..90f89b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ Lines marked **Upgrade step** need you to act after updating. - Browser Back now leaves a form's Settings page. Before, every tab you opened added a history step, so Back only went through the tabs. A link to a Settings tab still opens that tab. (#287) - A new long text question now shows in your form and in the editor preview, also before you set any options for it. Before, it failed to show. (#286) - A long text question with an empty "Max characters" now has no limit, also when you open it again. Before, the editor showed 500 and saved it back. New long text questions still start at 500. (#291) -- A very long answer is now measured the way the form stores it, so it is refused with a clear message and nothing is saved. Before, a long answer with umlauts or emoji could end in a server error. (#PR) +- A very long answer is now measured the way the form stores it, so it is refused with a clear message and nothing is saved. Before, a long answer with umlauts or emoji could end in a server error. (#293) - Ticking "Remember me" on the login page now keeps you logged in after your browser session ends. Before, it was ignored. (#227) - The counter under the text editor shows the length of your text as soon as a text opens. Before, it showed a larger number until your first edit. (#228) - A question or completion page text that holds only an image keeps the image when you save it. Before, it was saved as empty and the image was lost. (#229) From cc75cd908e6fa1cc72c13ab2a723ad730248bb7b Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Sat, 17 Oct 2026 13:06:24 +0200 Subject: [PATCH 3/4] Check nested answer lists, fix changelog wording Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- app/Rules/AnswerFitsColumn.php | 23 +++++++++++++++-------- tests/Feature/FormSessionTest.php | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f89b21..789dc334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ Lines marked **Upgrade step** need you to act after updating. - Browser Back now leaves a form's Settings page. Before, every tab you opened added a history step, so Back only went through the tabs. A link to a Settings tab still opens that tab. (#287) - A new long text question now shows in your form and in the editor preview, also before you set any options for it. Before, it failed to show. (#286) - A long text question with an empty "Max characters" now has no limit, also when you open it again. Before, the editor showed 500 and saved it back. New long text questions still start at 500. (#291) -- A very long answer is now measured the way the form stores it, so it is refused with a clear message and nothing is saved. Before, a long answer with umlauts or emoji could end in a server error. (#293) +- Long answers are now accepted up to what the database can hold (about 36,000 characters), and a longer one is refused with a clear message and nothing is saved. Before, answers over 30,000 characters were refused with a technical message. (#293) - Ticking "Remember me" on the login page now keeps you logged in after your browser session ends. Before, it was ignored. (#227) - The counter under the text editor shows the length of your text as soon as a text opens. Before, it showed a larger number until your first edit. (#228) - A question or completion page text that holds only an image keeps the image when you save it. Before, it was saved as empty and the image was lost. (#229) diff --git a/app/Rules/AnswerFitsColumn.php b/app/Rules/AnswerFitsColumn.php index 681e32d1..74c320eb 100644 --- a/app/Rules/AnswerFitsColumn.php +++ b/app/Rules/AnswerFitsColumn.php @@ -10,16 +10,23 @@ class AnswerFitsColumn implements ValidationRule { public function validate(string $attribute, mixed $value, Closure $fail): void { - // a block sends one answer, or a list of answers (checkbox, consent) - $answers = is_array($value) && ! has_string_keys($value) ? $value : [$value]; + if ($this->tooLong($value)) { + $fail('This answer is too long. Please shorten it and send it again.'); + } + } - foreach ($answers as $answer) { - // measured the way it is stored: encrypted, or as JSON in debug mode - if (is_array($answer) && strlen(FormSessionResponse::toStored($answer['payload'] ?? null)) > FormSessionResponse::VALUE_MAX_BYTES) { - $fail('This answer is too long. Please shorten it and send it again.'); + // walks lists at any depth, the way FormBlock::submit does + private function tooLong(mixed $value): bool + { + if (! is_array($value)) { + return false; + } - return; - } + if (has_string_keys($value)) { + // measured the way it is stored: encrypted, or as JSON in debug mode + return strlen(FormSessionResponse::toStored($value['payload'] ?? null)) > FormSessionResponse::VALUE_MAX_BYTES; } + + return collect($value)->contains(fn ($answer) => $this->tooLong($answer)); } } diff --git a/tests/Feature/FormSessionTest.php b/tests/Feature/FormSessionTest.php index 75f0c02e..d4c7a282 100644 --- a/tests/Feature/FormSessionTest.php +++ b/tests/Feature/FormSessionTest.php @@ -392,6 +392,21 @@ expect(FormSessionResponse::count())->toBe(0); })->with('templates'); +test('a too-long answer inside nested lists is rejected and not stored', function ($template) { + $form = Form::factory()->create(); + $form->applyTemplate($template); + $session = FormSession::factory()->create(['form_id' => $form->id]); + $block = $form->formBlocks[0]; + $answer = $block->getSubmitPayload(str_repeat('a', 70000))[$block->uuid]; + + $this->json('POST', route('api.public.forms.submit', ['form' => $form->uuid]), [ + 'token' => $session->token, + 'payload' => [$block->uuid => [[$answer]]], + ])->assertStatus(422)->assertJsonValidationErrors('payload.'.$block->uuid); + + expect(FormSessionResponse::count())->toBe(0); +})->with('templates'); + test('more answers than the form has blocks are rejected', function ($template) { $form = Form::factory()->create(); $form->applyTemplate($template); From 5bf280a531f6062c75adc2f0a1873c63e9e71bc2 Mon Sep 17 00:00:00 2001 From: Philipp Reinking Date: Sat, 17 Oct 2026 13:12:43 +0200 Subject: [PATCH 4/4] Changelog: say plain letters, move line to end of Fixed Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 789dc334..3afc884d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,6 @@ Lines marked **Upgrade step** need you to act after updating. - Browser Back now leaves a form's Settings page. Before, every tab you opened added a history step, so Back only went through the tabs. A link to a Settings tab still opens that tab. (#287) - A new long text question now shows in your form and in the editor preview, also before you set any options for it. Before, it failed to show. (#286) - A long text question with an empty "Max characters" now has no limit, also when you open it again. Before, the editor showed 500 and saved it back. New long text questions still start at 500. (#291) -- Long answers are now accepted up to what the database can hold (about 36,000 characters), and a longer one is refused with a clear message and nothing is saved. Before, answers over 30,000 characters were refused with a technical message. (#293) - Ticking "Remember me" on the login page now keeps you logged in after your browser session ends. Before, it was ignored. (#227) - The counter under the text editor shows the length of your text as soon as a text opens. Before, it showed a larger number until your first edit. (#228) - A question or completion page text that holds only an image keeps the image when you save it. Before, it was saved as empty and the image was lost. (#229) @@ -75,6 +74,7 @@ Lines marked **Upgrade step** need you to act after updating. - A webhook answer that isn't valid UTF-8 text is now saved and shows on the submissions page, with each unreadable character as "?". Before, it wasn't saved and the webhook was called up to 5 times. With `QUEUE_CONNECTION=sync`, the submission itself failed with a server error. (#284) - Opening a question in the editor no longer saves its answer settings, so "Saving…" does not show up for nothing. They save only when you change a setting. (#288) - When a save fails, the red bar with Retry no longer makes the editor taller than your window. The page stays as tall as the window, so your last question and the end screen stay in reach. (#290) +- Long answers are now accepted up to what the database can hold (about 36,000 plain letters, fewer with accents, emoji or non-Latin script), and a longer one is refused with a clear message and nothing is saved. Before, the limit was lower (about 30,000 plain letters) and the refusal was a technical message. (#293) ### Security