SUPP-53: buffer url-encoded request bodies so the security filter cannot drain them #59

Merged
mph merged 20 commits from SUPP-53 into release/0.1.x 2026-09-14 20:08:52 +00:00
Member

Closes #53.

Summary

Pac4jHttpSecurity drained url-encoded request bodies before the route could read
them, so an authenticated form POST could reach its handler with an empty form and
still answer 200. Nothing failed and nothing was logged — the loss was silent, and
intermittent, because it depended on how the server framed the body on the wire.

The security filter needs to read form parameters, and http4s request bodies are
single-read streams. The fix buffers the body once, before the security filter runs,
so both the filter and the route read the same bytes.

Defect fixed

Phase 1 — Security filter drains the request body before the route reads it
(merged via #54).

  • New UrlEncodedBodyBuffer middleware buffers a url-encoded request body into memory
    and rebuilds the request around it, up to a configured cap.
  • The rebuilt request carries framing headers describing the buffered body:
    Content-Length is set to the measured size and chunked is dropped, so the
    headers no longer describe the original stream.
  • Pac4jHttpSecurity.secure wires the middleware in ahead of the security filter.
  • Pac4jSecurityConfig gains a trailing maxFormBodySize: Long, read from
    security.maxformbodysize, default 2 MiB.

Only application/x-www-form-urlencoded requests are buffered; every other content
type passes through untouched.

Two consequences worth reading before upgrading

  • Pac4jSecurityConfig has a new trailing field. Positional destructuring or
    unapply over it will no longer compile. Named construction and config-file
    loading are unaffected.
  • A body over the cap is rejected with a bare 413, no body. htmx will not swap
    such a response, so the user sees nothing happen. Applications that accept large
    forms should raise the cap or handle the 413 themselves.

Testing

19 regression tests added — 17 in UrlEncodedBodyBufferSpec, 2 in
Pac4jSecurityConfigSpec. The server/http module goes from 52 to 71 tests.

There was no HTTP-level test fixture in server/http/src/test before this work; the
harness (a single-read Ref-backed body stream, probe routes, a pac4j
AnonymousClient config) was written from scratch and is reusable.

Every test was proved fallible by mutation: removing the fix wiring fails the four
reproduction tests while the four guards keep passing, with the headline test failing
as form = Map() at status 200 rather than as an exception.

Verified locally: ./mill __.test green (pre-push hook, whole repo), ./mill http.checkFormat clean, 0 compiler warnings.

Also in this branch

54896fae points .iw/config.conf at the Forgejo tracker (it still said github).
Unrelated to the defect, but the workflow tooling needed it to read this issue. Say
the word and I will pull it out into its own change.

Follow-ups

Filed to the tracker rather than fixed here: #55, #56, #57, #58.

Release notes

project-management/issues/SUPP-53/release-notes.md (Czech), written for 0.1.24.

Delivery

This PR targets release/0.1.x — the consumer pins 0.1.23, so the fix ships as
0.1.24 on the maintenance line. A forward-port to main follows, as with #45/#46.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT

Closes #53. ## Summary `Pac4jHttpSecurity` drained url-encoded request bodies before the route could read them, so an authenticated form POST could reach its handler with an empty form and still answer 200. Nothing failed and nothing was logged — the loss was silent, and intermittent, because it depended on how the server framed the body on the wire. The security filter needs to read form parameters, and http4s request bodies are single-read streams. The fix buffers the body once, before the security filter runs, so both the filter and the route read the same bytes. ## Defect fixed **Phase 1 — Security filter drains the request body before the route reads it** (merged via #54). - New `UrlEncodedBodyBuffer` middleware buffers a url-encoded request body into memory and rebuilds the request around it, up to a configured cap. - The rebuilt request carries framing headers describing the buffered body: `Content-Length` is set to the measured size and `chunked` is dropped, so the headers no longer describe the original stream. - `Pac4jHttpSecurity.secure` wires the middleware in ahead of the security filter. - `Pac4jSecurityConfig` gains a trailing `maxFormBodySize: Long`, read from `security.maxformbodysize`, default 2 MiB. Only `application/x-www-form-urlencoded` requests are buffered; every other content type passes through untouched. ## Two consequences worth reading before upgrading - `Pac4jSecurityConfig` has a **new trailing field**. Positional destructuring or `unapply` over it will no longer compile. Named construction and config-file loading are unaffected. - A body over the cap is rejected with a **bare `413`, no body**. htmx will not swap such a response, so the user sees nothing happen. Applications that accept large forms should raise the cap or handle the 413 themselves. ## Testing 19 regression tests added — 17 in `UrlEncodedBodyBufferSpec`, 2 in `Pac4jSecurityConfigSpec`. The `server/http` module goes from 52 to 71 tests. There was no HTTP-level test fixture in `server/http/src/test` before this work; the harness (a single-read `Ref`-backed body stream, probe routes, a pac4j `AnonymousClient` config) was written from scratch and is reusable. Every test was proved fallible by mutation: removing the fix wiring fails the four reproduction tests while the four guards keep passing, with the headline test failing as `form = Map()` at status 200 rather than as an exception. Verified locally: `./mill __.test` green (pre-push hook, whole repo), `./mill http.checkFormat` clean, 0 compiler warnings. ## Also in this branch `54896fae` points `.iw/config.conf` at the Forgejo tracker (it still said `github`). Unrelated to the defect, but the workflow tooling needed it to read this issue. Say the word and I will pull it out into its own change. ## Follow-ups Filed to the tracker rather than fixed here: #55, #56, #57, #58. ## Release notes `project-management/issues/SUPP-53/release-notes.md` (Czech), written for **0.1.24**. ## Delivery This PR targets `release/0.1.x` — the consumer pins 0.1.23, so the fix ships as **0.1.24** on the maintenance line. A forward-port to `main` follows, as with #45/#46. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT
Pac4jHttpSecurity: the security filter consumes url-encoded request
bodies, so a route can receive an empty form

Artifacts:
- analysis.md
- FOLLOW-UPS.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT
Michal's decisions:
- maxFormBodySize is a defaulted trailing field; this one
  backward-compatibility measure is approved
- The 413 carries no entity; presentation is the consumer's

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT
Pac4jHttpSecurity: the security filter consumes url-encoded request
bodies, so a route can receive an empty form

Artifacts:
- tasks.md
- review-state.json (tasks_ready)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT
Issue: SUPP-53
Phase: 1 - Security filter drains the request body before the route reads it

This commit marks the checkpoint for phase 1 context generation.
Use dx-fix SUPP-53 to return to this point.

The context review also found a narrower claim than the source supports:
a route with no client list is not exempt. SecurityFilterMiddleware passes
clients.orNull and DefaultSecurityClientFinder resolves a null list from the
pac4j Clients before the blank check, so Pac4jModuleRegistry's own default
of None is exposed. analysis.md and FOLLOW-UPS.md are corrected to agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT
The repository has no GitLab or GitHub remote for code: origin is Forgejo
at code.iterative.works. `iw project-context` read `type = github` and
reported GitLab with glab instructions, which no command here can follow.

Set `type = forgejo` and add `tracker.baseUrl`, so project-context reports
the Forgejo instructions and `iw phase-pr` / `iw phase-merge` can reach the
API directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT
Security filter drains the request body before the route reads it

14 tasks in the sequencing of the phase context: reproduce with the
singleReadBody fixture and Test 1, then the trailing maxFormBodySize
config field, then UrlEncodedBodyBuffer and the .andThen wiring, then
the guards, the size-limit cases and the full server/http run.

The root cause is confirmed in source, so the investigation task is a
short confirmation that Test 1 fails as an empty form with status 200
rather than an open-ended hypothesis hunt.

Artifacts:
- phase-01-tasks.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT
- Buffer url-encoded bodies into a strict chunk ahead of the pac4j filter, so a single-read body reaches both the filter and the route intact
- Reject an oversized url-encoded body with a bare 413, measured rather than trusting Content-Length
- Rebuild the request with framing headers describing its buffered body
- Reject a size limit outside (0, Int.MaxValue) at construction, closing an overflow that would pass an emptied body through
- Add 19 regression tests and the HTTP-level test harness the module lacked
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT
Repo CI triggers only on pull requests into main or release/*, so it does
not run on a phase PR into the feature branch. The four checks it would
have applied were run locally across every module and all pass:
__.compile, __.checkFormat, __.fix --check and __.test (436 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT
Reviewed-on: #54
All four lines from FOLLOW-UPS.md are now tracker issues; none matched an
existing issue, so each became a new one:

- #55 Pac4jCsrfSupport.tokenFor builds a body-reading web context
- #56 Body-borne pac4jCsrfToken fails CSRF check with a charset parameter
- #57 Four pac4j security files carry no PURPOSE header
- #58 maxformbodysize is not bounds-checked

The file is empty and removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT
docs(SUPP-53): release notes for 0.1.24
Some checks failed
CI / Check Linting (pull_request) Has been cancelled
CI / Run Tests (pull_request) Has been cancelled
CI / Compile (pull_request) Has been cancelled
CI / Check Formatting (pull_request) Has been cancelled
1f123a2fc3
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDfb2ghkkKiwTKVrcufmaT
chore(SUPP-53): update review-state to all_complete
All checks were successful
CI / Check Formatting (pull_request) Successful in 1m23s
CI / Compile (pull_request) Successful in 4m13s
CI / Check Linting (pull_request) Successful in 3m26s
CI / Run Tests (pull_request) Successful in 1m55s
81d99439d8
mph merged commit a9cd69b488 into release/0.1.x 2026-09-14 20:08:52 +00:00
Sign in to join this conversation.
No description provided.