Repository navigation
Allow reading the request body in both security and main server logic - #5491
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… reads Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…through comment ByteArrayBody and ByteBufferBody previously returned views over the same cached array; since byteArrayBody/byteBufferBody are identity codecs, a caller mutating the returned value in place silently corrupted the cache for subsequent reads. Now clone the array before handing it out for both representations. Also corrected the comment/class doc claiming file and multipart bodies "cannot be extracted" - they can be combined with an extracted body in the type system; that combination is instead rejected by EndpointVerifier at route construction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fix a stray pre-rename reference to EndpointVerifier in a CachingRequestBody comment (should be EndpointBodyVerifier), and run scalafmt on ExtractedBody.scala and Tapir.scala so core/scalafmtCheckAll passes on the lines this branch added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run scalafmt over DecodeBasicInputs.scala, CachingRequestBody.scala, and ServerInterpreterExtractedBodyTest.scala so serverCore/scalafmtCheckAll passes; churn stays confined to lines this branch introduced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fail fast at server startup instead of at request time: FilterServerEndpoints.apply now calls EndpointBodyVerifier.verify and throws IllegalArgumentException for structurally-invalid endpoints (e.g. two primary bodies split across securityIn/in). This covers netty, http4s, akka, pekko, play, armeria, jdkhttp, nima and the stubs. Backends that build routes from a single endpoint without going through FilterServerEndpoints (vertx future/cats/zio route and blockingRoute, finatra toRoute) get the equivalent verifyOne check; zio-http's list-shaped toHttp gets the verify check.
Extracted bodies (extractBodyFromRequest) share the wire body with the primary input, so they must not be documented as a second request body. Filters them out of OpenAPI paths/parameters, apispec schema generation, the default-400 heuristic, and the gRPC protobuf message interpreter.
Each client's setInputParams now short-circuits on EndpointIO.Body inputs marked as extracted (RichEndpointIOBody#isExtracted), skipping them instead of encoding them onto the outgoing request. There is only one body on the wire; the extracted declaration exists purely so server-side security logic can decode it a second time.
Adds a ServerSecurityTests case pairing extractBodyFromRequest(stringBody) in securityIn with stringBody in the main in, asserting both the security and main logic see the same decoded payload, plus a companion case asserting a security failure short-circuits cleanly. Runs across all ~22 wired backend test modules.
Explains how to mark a duplicate request body read in serverSecurityLogic with extractBodyFromRequest, why it stays off the wire and out of the generated docs, and the compile-time restriction to replayable raw body types. Documents EndpointBodyVerifier: errors thrown at route construction vs. warnings, which are surfaced only by calling the verifier directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…anyPrimaries wording primaryBodies treated OneOfBody as an opaque atom, so streamingPrimary and nonReplayablePrimary never inspected its variants: an extracted body combined with a oneOfBody of streaming or file/multipart variants passed verification and reproduced the double-subscribe bug at request time. Flatten variants via bodyAsAtom before those checks. Also compute primary body counts per container so the "declares a request body in both securityIn and in" message is only used when that's actually the shape; two bodies in the same container now get accurate wording.
Inline pattern match in flatMap failed to type-check on 2.12 (existential wildcard binding across branches); extracting it into a small helper with an explicit signature fixes it on both 2.12 and 2.13.
Discard the unused interpreter.apply result in ServerInterpreterExtractedBodyTest, and drop the now-unused idMonad val and its imports from FilterServerEndpointsTest (Identity is still used by serverSecurityLogic[Unit, Identity]).
Explain that an ordinary file/multipart/streaming body can't be combined with an extracted body either, and call out that two ordinary bodies (.securityIn(stringBody).in(stringBody)) now throw IllegalArgumentException at route construction - a breaking change for backends that used to buffer eagerly (Vert.x, Armeria, Play) - with the one-line extractBodyFromRequest migration.
…protobuf - Mark CachingRequestBody.cachedBytes @volatile; cheap given the whole body is already buffered, and removes an argument from review. - Document in CachingRequestBody.bytes that the security and main decode phases always pass the same maxBytes, because se.info and se.endpoint.info are the same object. - EndpointToProtobufService.forIO now skips extracted bodies, matching EndpointToProtobufMessage, so a .proto file can no longer reference an undefined message for a body that's excluded from the API contract.
… joining throwOnErrors is a validation entry point, not a path-routing helper, so it belongs next to verify/verifyOne rather than on FilterServerEndpoints. Update all call sites: FilterServerEndpoints, zio-http, the three vertx interpreters, and finatra. Pure move, no behaviour change except joining multiple error messages with newlines instead of spaces, so several invalid endpoints don't run together into one unreadable line.
The parameter shadowed the class field of the same name, while decodeStreamingBody two lines away deliberately reads the field. Rename it throughout decodeBody (both overloads), decodeExtractedBodies, and decodeExtractedBody so the distinction between "this call's body reader" and "the interpreter's underlying request body" is visible. No behaviour change.
Asserts maxBytes is passed through to the delegate on the first read, and that a later read with a different maxBytes has no effect once the body is cached.
Pattern-matching on RawBodyType already refines R, so most asInstanceOf calls were unnecessary; the InputStream case uses an ascription instead. Comments trimmed to one line each, and the @volatile comment no longer claims a plain var is what's used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the throwOnErrors doc, which repeated the object's and added nothing over the method name, and cut the duplicated sentence from EndpointBodyProblems. The object doc now records the one non-obvious fact instead: warnings are not logged, so callers must assert on them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the mechanism detail (ReplayableRawBody instances, which bodies are EndpointIO.Body values) - readers need the restriction, not how it's implemented - and cut the drained-request rationale from the paragraph that follows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ared Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No high-severity bugs found. In both orders (secondary body in Design
Correctness / security
API compatibility
TestsThe tests can fail when they should, and the cross-backend tests run on all servers, including Netty/jdkhttp/Nima where the body can be read only once. Gaps:
Minor
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A secondary body next to a primary one in the same securityIn or in group is rejected when routes are built, so each group has a single body slot. This drops the extra secondary-body decoding pass and keeps DecodeBasicInputsResult.Values unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On akka, pekko and armeria, toRaw decoded string bodies separately from decodeStringBody, which copied its rule. Both now go through one definition, so a buffered body can't drift from a directly read one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scaladoc keeps a one-line definition of the field; testing.md carries the explanation. The security.md section states the same rules in fewer paragraphs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixed - bodies are now encoded as UTF-8 when read as bytes.
Fixed - now a second body in the same group is rejected.
Fixed by the change for #2.
Fixed by the change for #2.
We already talked about it in #5491 (comment), and decided to not put verification in the filtering method.
The need for a DSL was discussed in #4442, and I still think the points there are valid. Also I checked that buffering in the suggested way would cause problems with Content-Type.
A new EndpointIO subtype would touch every input match in the codebase and break binary compatibility. The clients' recursive matches can't use a filtering traversal either.
It's true, but intended. Docs never validate endpoints, and the guards prevent the dangling $ref mentioned here: #5491 (comment) .
Yes, but it's a change from your commit - do you want me to fix it?
Fixed - shared the rule with toRaw on akka, pekko and armeria.
A field was one of your suggestions here: #5491 (comment) and I liked it. If you prefer, I can change it to a case class per problem, but that would add ~9 new public types.
Fixed. |
RequestBody.decodeStringBody is replaced by stringBodyCharset, which only says which charset a backend uses for string bodies. Cached string bodies are always decoded with new String(bytes, charset). When the request doesn't specify a charset, Armeria, Akka and Pekko now use the body codec's charset instead of always using UTF-8.
A secondary body can be declared in either securityIn or in.
…vers RequestBody.requestCharset reads the charset from the Content-Type header. Every server's toRaw and CachingRequestBody use it, falling back to the codec's charset, so the per-backend stringBodyCharset hook is gone. Netty, jdkhttp, Nima, zio-http, Vert.x and the stubs now honour the request's charset. AWS Lambda decodes text bodies from their UTF-8 bytes, like the cached path does.
…om-request-4442 # Conflicts: # client/play-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala # client/play29-client/src/main/scala/sttp/tapir/client/play/EndpointToPlayClient.scala # docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToParameters.scala # server/armeria-server/cats/src/main/scala/sttp/tapir/server/armeria/cats/TapirCatsService.scala # server/armeria-server/zio/src/main/scala/sttp/tapir/server/armeria/zio/TapirZioService.scala # server/netty-server/cats/src/main/scala/sttp/tapir/server/netty/cats/NettyCatsServerInterpreter.scala # server/netty-server/sync/src/main/scala/sttp/tapir/server/netty/sync/NettySyncServerInterpreter.scala # server/pekko-http-server/src/main/scala/sttp/tapir/server/pekkohttp/PekkoHttpServerInterpreter.scala # server/sttp-stub4-server/src/main/scala/sttp/tapir/server/stub4/TapirStubInterpreter.scala # server/vertx-server/src/main/scala/sttp/tapir/server/vertx/VertxFutureServerInterpreter.scala # server/zio-http-server/src/main/scala/sttp/tapir/server/ziohttp/ZioHttpInterpreter.scala
Fixes #4442.
The problem
An endpoint that reads the request body in
serverSecurityLogicand again in the main logic crashes on streaming backends:The failure is backend-dependent, so today the DSL permits something whose success is accidental per interpreter:
The approach
Mark one of the two declarations with
asSecondary:The secondary body is decoded on the server from a buffered copy of the request body. It isn't part of the API contract: it's left out of the documentation, and clients don't send it (they still take a value for it).
Only bodies which can be re-read from buffered bytes can be secondary:
String,Array[Byte],ByteBuffer,InputStream,InputStreamRange. Others are rejected at compile time.Release notes
asSecondary; hiding its schema isn't enough.EndpointVerifierreports these problems as a newInvalidBodyDefinitionError, so existingEndpointVerifier(...) shouldBe emptychecks may start failing.Content-Typecharset, falling back to the codec's charset. Before, Netty, jdkhttp, Nima, zio-http, Vert.x and the sttp stubs ignored the request's charset, and Akka/Pekko fell back to UTF-8.PrepareServerEndpointsinstead ofFilterServerEndpoints, and decode whole string bodies withRequestBody.requestCharset(request).getOrElse(codecCharset).