Pac4jHttpSecurity: the security filter consumes url-encoded request bodies, so a route can receive an empty form #53
Labels
No labels
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
iterative-works/support#53
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while investigating intermittent E2E failures in
medeca-modul-poptavky(MEDECA-497).The shape of the problem
The pac4j security filter reads the body of every
application/x-www-form-urlencodedrequest, and then hands the route the same request whose body it has already consumed. When the server gives the body as a single-read socket stream, the route decodes an empty form — with no error.The chain, each link read in source (versions as resolved by iw-support 0.1.23):
Pac4jHttpSecurity.contextBuilderbuilds the web context with a body extractor over the live request:new Http4sWebContext[F](req, _ => dispatcher.unsafeRunSync(req.bodyText.compile.string))(server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala:32-33).baseSecurityFilterpasses it toSecurityFilterMiddleware.securityFilter(:75-99);securewraps that (:116-130);Pac4jModuleRegistry.scala:24callssecure(clients = clients).DefaultSecurityLogiccallsclientFinder.findon every request (6.3.2:99), andDefaultSecurityClientFinder.findcallscontext.getRequestParameter(clientNameParameter)(defaultforce_client) whenever client names are set (6.0.3:53, 6.3.2:54).Http4sWebContext.getRequestParameter(:54-60) andgetRequestParameters(:67-70) decode the body when the Content-Type is exactlyapplication/x-www-form-urlencoded; for any other type they read the query string only.getRequestContentis a lazy val (:183-184).DefaultSecurityGrantedAccessAdapterpassescontext.getRequestdownstream (SecurityFilterMiddleware.scala:30-32) — the request whose body pac4j has read.Http1Stage.scala:215-235). Otherwise the body is a socket stream (streamingBody,:247-289), and a second run of it ends at once with no error (:285).net/http/http_stream_parser.cc,kMaxMergedHeaderAndBodySize; read on Chromiummain). A larger request goes out as two writes, so whether blaze's first read holds the body depends on timing.Result: a url-encoded POST/PUT of more than about 1400 bytes (headers plus body) sometimes reaches the route with an empty form. In MEDECA-497 a template-preview POST (~544 B body) returned a preview built from an empty form in about 16% of CI executions; a 221 B create request never failed in the same 37 jobs.
Status: every link above is read in source. The split read has not yet been observed at runtime — a split-write probe is planned in MEDECA-497. Independent of that, reading a single-read stream twice is a defect in this module.
Why the consumer cannot fix it cleanly
val contextBuilderin a subclass does not work: the parent constructor buildscallbackServiceand both logout services with it (Pac4jHttpSecurity.scala:43-71), so the override is stillnullat that point.def baseSecurityFilter, but only by copying its body, which then hides every later change made here.secure(...)with a client list has the exposure. The fix belongs in this module.Constraints on the fix
docs/CSRF_GUIDE.md:195-202documents a hidden-fieldpac4jCsrfTokenthat travels in the url-encoded body, and at least one consumer (cmi-portaly) uses it.DefaultSavedRequestHandler.save(pac4j-core 6.3.2:32-35) also builds the post-login replay fromgetRequestParameters. A security-filter context that never reads the body is therefore not acceptable.multipart/form-data,application/octet-stream(e.g. the streaming upload inFileStoreEndpointsModulePlatformSpecific.scala:27-29), JSON — must reach the route as the original, unread stream. Do not use an unconditionalBodyCache.application/x-www-form-urlencodedwith a charset parameter; buffering that superset is safe.Pac4jSecurityConfig, default 2 MiB (2097152 bytes — the same default as Tomcat'smaxPostSize, which also applies only to bodies parsed into parameters). Count the encoded body bytes. Reject with 413 whenContent-Lengthexceeds the limit, before reading; for a body withoutContent-Length, stop reading at limit + 1 byte and reject. Today there is no limit on this path at all.form_postcallback needs the body). They read it once and nothing reads it after them; confirm that they need no change.Suggested direction
Inside
baseSecurityFilter(or a small middleware thatsecureapplies before the pac4j filter): for a url-encoded request, read the body once within the limit, rebuild the request with a strict body holding those bytes, and build theHttp4sWebContextfrom the rebuilt request. BecauseSecurityFilterMiddlewarehandscontext.getRequestto the route, the route then decodes the same bytes. Every other request passes through untouched.Tests
Ref, emitting its bytes once and nothing on a second run), throughsecure(clients = Some(...)), to a route that decodesUrlForm: the route sees every field. This test must fail on the current code.pac4jCsrfTokenhidden field is accepted by the CSRF check).Content-Length; a body exactly at the limit passes.application/x-www-form-urlencoded; charset=UTF-8is buffered too.Delivery
release/0.1.x, release 0.1.24 (the consumer pins 0.1.23), then forward-port tomain— the same pattern as #45 / #46.supportVersionbump and E2E verification) is tracked inmedeca-modul-poptavkyMEDECA-497.Note for consumers
htmx does not swap a 4xx response by default, so a 413 on an htmx request shows the user nothing. Consumers whose forms can approach the limit should make the rejection visible.
Reference:
medeca-modul-poptavky,project-management/issues/MEDECA-497/analysis.md, Defect 1 > Hypothesis 1 (on branchMEDECA-497, local worktree~/Devel/projects/medeca-modul-poptavky-MEDECA-497; not pushed yet).