Skip to content

Commit 8399a52

Browse files
authored
fix: reject a request body on a body-forbidden method (GET/HEAD/TRACE) (#135)
PR: #135
1 parent 7f54ccb commit 8399a52

9 files changed

Lines changed: 262 additions & 39 deletions

File tree

sdk-core/api/sdk-core.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,6 +1033,7 @@ public final class org/dexpace/sdk/core/http/request/Method : java/lang/Enum {
10331033
public static final field TRACE Lorg/dexpace/sdk/core/http/request/Method;
10341034
public static fun getEntries ()Lkotlin/enums/EnumEntries;
10351035
public final fun getMethod ()Ljava/lang/String;
1036+
public final fun getPermitsRequestBody ()Z
10361037
public fun toString ()Ljava/lang/String;
10371038
public static fun valueOf (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Method;
10381039
public static fun values ()[Lorg/dexpace/sdk/core/http/request/Method;

sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/Method.kt

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,30 @@ package org.dexpace.sdk.core.http.request
1515
* request line without translation.
1616
*
1717
* @property method Canonical uppercase method token sent in the request line.
18+
* @property permitsRequestBody Whether this SDK permits the method to carry a request body.
19+
* `false` for `GET`, `HEAD`, `TRACE`, and `CONNECT`. Of these only `TRACE` is forbidden a body
20+
* outright by HTTP — a TRACE request "MUST NOT send content" (RFC 9110 §9.3.8); for `GET`/`HEAD`
21+
* (§9.3.1/§9.3.2) and `CONNECT` (§9.3.6) a request payload has no generally defined semantics
22+
* and is discouraged rather than illegal. The SDK rejects a body on all four as one consistent
23+
* policy, because the reference transports otherwise diverge on the case — OkHttp throws
24+
* `IllegalArgumentException`, the JDK builder silently ignores the body — so it is rejected once
25+
* at request construction (see `Request.RequestBuilder.build`) rather than left to behave
26+
* differently per transport.
1827
*/
1928
@Suppress("unused")
2029
public enum class Method(
2130
public val method: String,
31+
public val permitsRequestBody: Boolean,
2232
) {
23-
GET("GET"),
24-
POST("POST"),
25-
PUT("PUT"),
26-
DELETE("DELETE"),
27-
PATCH("PATCH"),
28-
HEAD("HEAD"),
29-
OPTIONS("OPTIONS"),
30-
TRACE("TRACE"),
31-
CONNECT("CONNECT"),
33+
GET("GET", permitsRequestBody = false),
34+
POST("POST", permitsRequestBody = true),
35+
PUT("PUT", permitsRequestBody = true),
36+
DELETE("DELETE", permitsRequestBody = true),
37+
PATCH("PATCH", permitsRequestBody = true),
38+
HEAD("HEAD", permitsRequestBody = false),
39+
OPTIONS("OPTIONS", permitsRequestBody = true),
40+
TRACE("TRACE", permitsRequestBody = false),
41+
CONNECT("CONNECT", permitsRequestBody = false),
3242
;
3343

3444
override fun toString(): String = method

sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/request/Request.kt

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ import java.net.URL
3737
* @property method HTTP method on the wire.
3838
* @property url Fully-resolved target URL.
3939
* @property headers Request headers; may be empty but never `null`.
40-
* @property body Request body, or `null` for methods without a payload (typical for GET/HEAD).
40+
* @property body Request body, or `null` for methods without a payload. A non-`null` body is
41+
* only valid on a method that permits one ([Method.permitsRequestBody]); building a `GET`,
42+
* `HEAD`, `TRACE`, or `CONNECT` request with a body throws `IllegalArgumentException`.
4143
*/
4244
@ConsistentCopyVisibility
4345
public data class Request private constructor(
@@ -117,12 +119,16 @@ public data class Request private constructor(
117119
}
118120

119121
/**
120-
* Sets the request body.
122+
* Sets the request body, or clears it when [body] is `null`.
121123
*
122-
* @param body The request body.
124+
* Passing `null` is the way to drop a body carried over by [newBuilder] — for example
125+
* when downgrading a body-carrying request to `GET`, `HEAD`, `TRACE`, or `CONNECT`, whose
126+
* [build] rejects a non-`null` body ([Method.permitsRequestBody] is `false` for them).
127+
*
128+
* @param body The request body, or `null` to clear any previously-set body.
123129
* @return This builder.
124130
*/
125-
public fun body(body: RequestBody): RequestBuilder =
131+
public fun body(body: RequestBody?): RequestBuilder =
126132
apply {
127133
this.body = body
128134
}
@@ -247,16 +253,32 @@ public data class Request private constructor(
247253
/**
248254
* Builds the [Request].
249255
*
256+
* A body set on a method that forbids one ([Method.permitsRequestBody] is `false` —
257+
* `GET`, `HEAD`, `TRACE`, `CONNECT`) is rejected here rather than passed to a transport:
258+
* the two reference transports disagree on the case (OkHttp throws, the JDK builder drops
259+
* the body and may consume a single-use stream for nothing), so the SDK fails fast at
260+
* construction with one consistent error instead. To downgrade a body-carrying request to
261+
* one of these methods, clear the body first with `body(null)`.
262+
*
250263
* @return The built request.
251264
* @throws IllegalStateException If a required field is missing.
265+
* @throws IllegalArgumentException If a body is set on a method that forbids one
266+
* ([Method.GET], [Method.HEAD], [Method.TRACE], or [Method.CONNECT]).
252267
*/
253-
override fun build(): Request =
254-
Request(
255-
method = checkRequired("method", method),
256-
url = checkRequired("url", url),
268+
override fun build(): Request {
269+
val resolvedMethod = checkRequired("method", method)
270+
val resolvedUrl = checkRequired("url", url)
271+
require(body == null || resolvedMethod.permitsRequestBody) {
272+
"$resolvedMethod must not carry a request body; remove the body or use a " +
273+
"method that permits one (POST, PUT, PATCH, DELETE, or OPTIONS)."
274+
}
275+
return Request(
276+
method = resolvedMethod,
277+
url = resolvedUrl,
257278
headers = headersBuilder.build(),
258279
body = body,
259280
)
281+
}
260282
}
261283

262284
public companion object {

sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/MethodTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,18 @@ class MethodTest {
4949
fun `entries enumerates exactly nine methods`() {
5050
assertEquals(9, Method.entries.size)
5151
}
52+
53+
@Test
54+
fun `permitsRequestBody is false only for GET HEAD TRACE and CONNECT`() {
55+
assertEquals(false, Method.GET.permitsRequestBody)
56+
assertEquals(false, Method.HEAD.permitsRequestBody)
57+
assertEquals(false, Method.TRACE.permitsRequestBody)
58+
assertEquals(false, Method.CONNECT.permitsRequestBody)
59+
60+
assertEquals(true, Method.POST.permitsRequestBody)
61+
assertEquals(true, Method.PUT.permitsRequestBody)
62+
assertEquals(true, Method.PATCH.permitsRequestBody)
63+
assertEquals(true, Method.DELETE.permitsRequestBody)
64+
assertEquals(true, Method.OPTIONS.permitsRequestBody)
65+
}
5266
}

sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/request/RequestTest.kt

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,102 @@ class RequestTest {
219219
assertEquals("url is required", ex.message)
220220
}
221221

222+
// ---------------------------------------------------------------------
223+
// Body / method compatibility — a body on a body-forbidden method is
224+
// rejected at build time so transports never have to disagree on it.
225+
// ---------------------------------------------------------------------
226+
227+
@Test
228+
fun `build rejects a body on a body-forbidden method`() {
229+
for (method in listOf(Method.GET, Method.HEAD, Method.TRACE, Method.CONNECT)) {
230+
val ex =
231+
assertFailsWith<IllegalArgumentException>("expected rejection for $method") {
232+
Request.builder()
233+
.url("https://example.test")
234+
.method(method)
235+
.body(RequestBody.create("x", null))
236+
.build()
237+
}
238+
assertTrue(
239+
ex.message!!.contains("$method must not carry a request body"),
240+
"unexpected message for $method: ${ex.message}",
241+
)
242+
}
243+
}
244+
245+
@Test
246+
fun `build allows a body on a body-permitting method`() {
247+
for (method in listOf(Method.POST, Method.PUT, Method.PATCH, Method.DELETE, Method.OPTIONS)) {
248+
val payload = RequestBody.create("x", null)
249+
val req =
250+
Request.builder()
251+
.url("https://example.test")
252+
.method(method)
253+
.body(payload)
254+
.build()
255+
assertEquals(payload, req.body, "body should be retained for $method")
256+
}
257+
}
258+
259+
@Test
260+
fun `build allows a body-less body-forbidden method`() {
261+
for (method in listOf(Method.GET, Method.HEAD, Method.TRACE, Method.CONNECT)) {
262+
val req =
263+
Request.builder()
264+
.url("https://example.test")
265+
.method(method)
266+
.build()
267+
assertNull(req.body, "body should stay null for $method")
268+
}
269+
}
270+
271+
@Test
272+
fun `newBuilder switching to a body-forbidden method while a body is set is rejected`() {
273+
val post =
274+
Request.builder()
275+
.url("https://example.test")
276+
.method(Method.POST)
277+
.body(RequestBody.create("x", null))
278+
.build()
279+
280+
assertFailsWith<IllegalArgumentException> {
281+
post.newBuilder().method(Method.GET).build()
282+
}
283+
}
284+
285+
@Test
286+
fun `body null clears a previously-set body`() {
287+
val req =
288+
Request.builder()
289+
.url("https://example.test")
290+
.method(Method.POST)
291+
.body(RequestBody.create("x", null))
292+
.body(null)
293+
.build()
294+
assertNull(req.body, "body(null) should clear the previously-set body")
295+
}
296+
297+
@Test
298+
fun `newBuilder downgrade to a body-forbidden method succeeds after clearing the body`() {
299+
val post =
300+
Request.builder()
301+
.url("https://example.test")
302+
.method(Method.POST)
303+
.body(RequestBody.create("x", null))
304+
.build()
305+
306+
// Clearing the body with body(null) is the supported way to downgrade a body-carrying
307+
// request to a method that forbids one.
308+
val get =
309+
post.newBuilder()
310+
.method(Method.GET)
311+
.body(null)
312+
.build()
313+
314+
assertEquals(Method.GET, get.method)
315+
assertNull(get.body, "downgraded GET must not retain the original body")
316+
}
317+
222318
// ---------------------------------------------------------------------
223319
// Equality — compares url by external form, never resolving DNS.
224320
// ---------------------------------------------------------------------

sdk-transport-jdkhttp/src/main/kotlin/org/dexpace/sdk/transport/jdkhttp/internal/RequestAdapter.kt

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,13 @@ internal class RequestAdapter(
6161
* that take a [HttpRequest.BodyPublisher] and those that force [HttpRequest.BodyPublishers.noBody]
6262
* reflects the HTTP semantics enforced by the JDK builder:
6363
*
64-
* - `GET` / `HEAD` / `TRACE` — no body. The JDK throws if a non-noBody publisher is supplied
65-
* on `HEAD`, so the adapter sends `noBody()` for these methods regardless of what the SDK
66-
* request carried. Callers passing a body to a GET/HEAD/TRACE request will see the body
67-
* silently dropped here.
64+
* - `GET` / `HEAD` / `TRACE` — no body. [Method.permitsRequestBody] is `false` for these (and
65+
* for `CONNECT`, which is rejected outright in its own branch below), so
66+
* `Request.RequestBuilder.build` rejects a body on them at construction; an SDK request can
67+
* never carry one here. The adapter sends `noBody()` unconditionally rather than adapting
68+
* `request.body` — there is nothing to adapt, and `noBody()` consumes nothing (the previous
69+
* code adapted then discarded the publisher, which for a small body drained a consume-once
70+
* `writeTo` for nothing).
6871
* - `POST` / `PUT` / `PATCH` / `DELETE` / `OPTIONS` — body publisher passed through. `DELETE`
6972
* and `OPTIONS` with a body are unusual but permitted by HTTP and the JDK builder.
7073
* - `CONNECT` — rejected; see [adapt]'s KDoc.
@@ -73,16 +76,15 @@ internal class RequestAdapter(
7376
builder: HttpRequest.Builder,
7477
request: SdkRequest,
7578
) {
76-
val publisher = BodyPublishers.adaptBody(request.body)
7779
when (request.method) {
7880
Method.GET -> builder.GET()
7981
Method.HEAD -> builder.method("HEAD", HttpRequest.BodyPublishers.noBody())
8082
Method.TRACE -> builder.method("TRACE", HttpRequest.BodyPublishers.noBody())
81-
Method.POST -> builder.POST(publisher)
82-
Method.PUT -> builder.PUT(publisher)
83-
Method.DELETE -> builder.method("DELETE", publisher)
84-
Method.PATCH -> builder.method("PATCH", publisher)
85-
Method.OPTIONS -> builder.method("OPTIONS", publisher)
83+
Method.POST -> builder.POST(BodyPublishers.adaptBody(request.body))
84+
Method.PUT -> builder.PUT(BodyPublishers.adaptBody(request.body))
85+
Method.DELETE -> builder.method("DELETE", BodyPublishers.adaptBody(request.body))
86+
Method.PATCH -> builder.method("PATCH", BodyPublishers.adaptBody(request.body))
87+
Method.OPTIONS -> builder.method("OPTIONS", BodyPublishers.adaptBody(request.body))
8688
Method.CONNECT -> throw IllegalArgumentException(
8789
"java.net.http.HttpClient does not support user-issued CONNECT requests. " +
8890
"Configure a proxy on the underlying HttpClient instead.",

sdk-transport-jdkhttp/src/test/kotlin/org/dexpace/sdk/transport/jdkhttp/JdkHttpTransportTest.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,43 @@ class JdkHttpTransportTest {
123123
assertEquals("/echo", recorded.url.encodedPath)
124124
}
125125

126+
// -------- body on a body-forbidden method (GET/HEAD/TRACE/CONNECT) --------
127+
128+
@Test
129+
fun `bodyOnForbiddenMethodIsRejectedBeforeDispatch`() {
130+
// The JDK builder ignores a body on GET/HEAD/TRACE, silently dropping it — and the eager
131+
// BodyPublishers path would already have drained a small consume-once body into a byte
132+
// array that is then discarded. The SDK rejects the body at request construction
133+
// (Request.RequestBuilder.build), so such a request is never built and never reaches the
134+
// transport, and no body is consumed for nothing.
135+
for (method in listOf(Method.GET, Method.HEAD, Method.TRACE, Method.CONNECT)) {
136+
assertFailsWith<IllegalArgumentException>("expected rejection for $method") {
137+
Request.builder()
138+
.method(method)
139+
.url(server.url("/forbidden-body").toUrl())
140+
.body(RequestBody.create("x", CommonMediaTypes.TEXT_PLAIN))
141+
.build()
142+
}
143+
}
144+
}
145+
146+
@Test
147+
fun `bodylessGetDispatchesWithNoBody`() {
148+
server.enqueue(MockResponse.Builder().code(200).build())
149+
val request =
150+
Request.builder()
151+
.method(Method.GET)
152+
.url(server.url("/bodyless-get").toUrl())
153+
.build()
154+
transport.execute(request).use { response ->
155+
assertEquals(200, response.status.code)
156+
}
157+
val recorded = server.takeRequest()
158+
assertEquals("GET", recorded.method)
159+
assertEquals("", recorded.body?.utf8() ?: "")
160+
assertEquals("/bodyless-get", recorded.url.encodedPath)
161+
}
162+
126163
// -------- async golden paths --------
127164

128165
@Test

sdk-transport-okhttp/src/main/kotlin/org/dexpace/sdk/transport/okhttp/internal/RequestAdapter.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ import org.dexpace.sdk.core.http.request.RequestBody as SdkRequestBody
3131
* body for POST/PUT/PATCH (the methods OkHttp treats as requiring one). The SDK model
3232
* makes a body optional for every method, so a body-less POST is a valid request; to keep
3333
* it from crashing with `IllegalArgumentException`, the adapter substitutes a zero-length
34-
* body for those methods (see [requiresRequestBody]). GET/HEAD/OPTIONS/TRACE/DELETE keep
35-
* their `null` body.
34+
* body for those methods (see [requiresRequestBody]). GET/HEAD/OPTIONS/TRACE/DELETE/CONNECT
35+
* keep their `null` body. The inverse case — a body on a body-forbidden method, which OkHttp's
36+
* `Request.Builder.method` rejects with `IllegalArgumentException("method GET must not have a
37+
* request body.")` — cannot reach here: `Request.RequestBuilder.build` rejects a body on
38+
* GET/HEAD/TRACE/CONNECT at construction, so `request.body` is always `null` for those methods.
3639
*
3740
* The adapter is stateless — the [logger] is the only field it carries so the DEBUG log
3841
* naming dropped headers attributes to the transport.

0 commit comments

Comments
 (0)