CR/LF injection in query parameter in hackney
Description
Improper Neutralization of CRLF Sequences vulnerability in benoitc hackney allows HTTP Request Splitting. hackney does not percent-encode carriage return (\r) or line feed (\n) characters in the URL query component before constructing the HTTP/1.1 request target. Characters outside the grammar defined in RFC 3986 Section 3.4 must be percent-encoded, but hackney_url:make_url/3 passes the query binary directly without validation or escaping. An attacker who can control all or part of a URL passed to hackney can inject raw CRLF sequences into the query string, which are then sent as HTTP line breaks in the request target. This enables injection of arbitrary HTTP headers or splitting of the HTTP request.
This issue affects hackney: from 0 before 4.0.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Hackney before 4.0.1 fails to sanitize CR/LF in URL query strings, enabling HTTP request splitting and header injection.
Vulnerability
Hackney versions from 0 up to but not including 4.0.1 do not percent-encode carriage return (\r) or line feed (\n) characters in the URL query component before constructing the HTTP/1.1 request target. The function hackney_url:make_url/3 passes the query binary directly without validation or escaping, contrary to RFC 3986 Section 3.4. This allows an attacker who controls any part of a URL passed to hackney to inject raw CRLF sequences into the query string, which are then transmitted verbatim as HTTP line breaks in the request target [1][2][4]. All versions from 0.* through 1.9.0 and 2.*, 3.* (as listed in the OSV database) are affected [1].
Exploitation
An attacker needs the ability to control all or part of a URL that is passed to hackney (e.g., via a user-supplied query parameter, redirect URL, or crafted link). No authentication is required, but user interaction (convincing an application using hackney to make a request to a malicious URL) may be necessary in some scenarios. A concrete proof of concept is provided: issuing :hackney.get("http://127.0.0.1:8080/?q=x HTTP/1.1\r\nX-Injected: yes\r\nX:") causes the query string ?q=x HTTP/1.1\r\nX-Injected: yes\r\nX: to be sent as the request target, forcing the server to parse X-Injected: yes as an additional header line [4].
Impact
Successful exploitation enables HTTP header injection and request splitting. An attacker can inject arbitrary headers such as Authorization, Host, or X-Forwarded-For, and can split the HTTP request to prepend a counterfeit request that may bypass security controls or poison caches. The impact is primarily on the integrity of the HTTP communication; the CVSS v4.0 score is 6.8 (MEDIUM) with high impact on integrity [2]. The vulnerability does not allow direct remote code execution but can facilitate further attacks such as session hijacking or server-side request forgery.
Mitigation
Upgrade to hackney version 4.0.1, which was released on 2025-05-26 and contains a fix that rejects requests containing CR, LF, or NUL bytes in the request target [3][4]. The commit ca73dd0aba0ed557449c18288bf07241671a43c9 introduces a valid_request_target/1 guard that returns {error, {invalid_request_target, Path}} when raw CR/LF/NUL bytes are present [3]. No workaround is available; all prior versions (0.x, 1.x, 2.x, 3.x) are vulnerable [1]. This CVE is not listed in CISA's Known Exploited Vulnerabilities (KEV) catalog as of publication.
AI Insight generated on May 25, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
1ca73dd0aba0efix(security): reject CR/LF/NUL in request target (GHSA-j9wq)
2 files changed · +58 −5
src/hackney_conn.erl+35 −5 modified@@ -261,22 +261,46 @@ request(Pid, Method, Path, Headers, Body, Timeout) -> -spec request(pid(), binary(), binary(), list(), binary() | iolist(), timeout(), list()) -> {ok, integer(), list()} | {ok, integer(), list(), binary()} | {error, term()}. request(Pid, Method, Path, Headers, Body, Timeout, ReqOpts) -> - gen_statem:call(Pid, {request, Method, Path, Headers, Body, ReqOpts}, Timeout). + case valid_request_target(Path) of + ok -> gen_statem:call(Pid, {request, Method, Path, Headers, Body, ReqOpts}, Timeout); + Err -> Err + end. + +%% @private GHSA-j9wq: the request target (path + query) is written verbatim +%% into the HTTP/1.1 request line and the HTTP/2 / HTTP/3 :path pseudo-header. +%% Raw CR, LF or NUL bytes let a caller-controlled URL inject extra header +%% lines or split the request. RFC 3986 requires those bytes to be +%% percent-encoded; reject them rather than emit a malformed request. +valid_request_target(Path) when is_binary(Path) -> + case binary:match(Path, [<<"\r">>, <<"\n">>, <<0>>]) of + nomatch -> ok; + _ -> {error, {invalid_request_target, Path}} + end; +valid_request_target(Path) when is_list(Path) -> + valid_request_target(iolist_to_binary(Path)); +valid_request_target(_) -> + ok. %% @doc Send an HTTP/3 request and return headers immediately. %% Returns {ok, Status, Headers} and allows subsequent stream_body/1 calls. %% This is for pull-based body streaming over HTTP/3. -spec request_streaming(pid(), binary(), binary(), list(), binary() | iolist()) -> {ok, integer(), list()} | {error, term()}. request_streaming(Pid, Method, Path, Headers, Body) -> - gen_statem:call(Pid, {request_streaming, Method, Path, Headers, Body}, infinity). + case valid_request_target(Path) of + ok -> gen_statem:call(Pid, {request_streaming, Method, Path, Headers, Body}, infinity); + Err -> Err + end. %% @doc Send only the request headers (for streaming body mode). %% After this, use send_body_chunk/2 and finish_send_body/1 to send the body, %% then start_response/1 to receive the response. -spec send_request_headers(pid(), binary(), binary(), list()) -> ok | {error, term()}. send_request_headers(Pid, Method, Path, Headers) -> - gen_statem:call(Pid, {send_headers, Method, Path, Headers}, infinity). + case valid_request_target(Path) of + ok -> gen_statem:call(Pid, {send_headers, Method, Path, Headers}, infinity); + Err -> Err + end. %% @doc Send a chunk of the request body. -spec send_body_chunk(pid(), iodata()) -> ok | {error, term()}. @@ -332,12 +356,18 @@ request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo) -> -spec request_async(pid(), binary(), binary(), list(), binary() | iolist(), true | once, pid(), boolean()) -> {ok, reference()} | {error, term()}. request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect) -> - gen_statem:call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect}). + case valid_request_target(Path) of + ok -> gen_statem:call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect}); + Err -> Err + end. -spec request_async(pid(), binary(), binary(), list(), binary() | iolist(), true | once, pid(), boolean(), list()) -> {ok, reference()} | {error, term()}. request_async(Pid, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts) -> - gen_statem:call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts}). + case valid_request_target(Path) of + ok -> gen_statem:call(Pid, {request_async, Method, Path, Headers, Body, AsyncMode, StreamTo, FollowRedirect, ReqOpts}); + Err -> Err + end. %% @doc Request the next message in {async, once} mode. -spec stream_next(pid()) -> ok | {error, term()}.
test/hackney_conn_tests.erl+23 −0 modified@@ -46,6 +46,7 @@ hackney_conn_integration_test_() -> {"stream body with stateful function", {timeout, 30, fun test_stream_body_stateful_fun/0}}, {"stream body function returns error", {timeout, 30, fun test_stream_body_fun_error/0}}, {"HEAD request", {timeout, 30, fun test_head_request/0}}, + {"GHSA-j9wq: CRLF in request target rejected", {timeout, 30, fun test_crlf_request_target_rejected/0}}, {"request returns to connected state", {timeout, 30, fun test_request_state_cycle/0}}, %% Async tests {"async request continuous", {timeout, 30, fun test_async_continuous/0}}, @@ -260,6 +261,28 @@ test_get_request() -> hackney_conn:stop(Pid). +%% GHSA-j9wq: a request target carrying raw CR/LF (e.g. from an +%% unsanitised query string) must be refused before anything is written to +%% the socket, otherwise the bytes split the request line into extra header +%% lines. +test_crlf_request_target_rejected() -> + Opts = #{ + host => "127.0.0.1", + port => ?PORT, + transport => hackney_tcp, + connect_timeout => 5000, + recv_timeout => 5000 + }, + {ok, Pid} = hackney_conn:start_link(Opts), + ok = hackney_conn:connect(Pid), + Evil = <<"/get?q=x HTTP/1.1\r\nX-Injected: yes\r\nX:">>, + ?assertEqual({error, {invalid_request_target, Evil}}, + hackney_conn:request(Pid, <<"GET">>, Evil, [], <<>>)), + %% Connection survives the rejection and still serves a clean request. + {ok, Status, _} = hackney_conn:request(Pid, <<"GET">>, <<"/get">>, [], <<>>), + ?assert(Status >= 200 andalso Status < 400), + hackney_conn:stop(Pid). + test_post_request() -> Opts = #{ host => "127.0.0.1",
Vulnerability mechanics
Root cause
"Missing percent-encoding of CR (`\r`) and LF (`\n`) characters in the URL query component before constructing the HTTP/1.1 request target."
Attack vector
An attacker who controls any portion of a URL passed to hackney can inject raw CRLF sequences into the query string [ref_id=2]. Because `hackney_url:make_url/3` does not percent-encode `\r` or `\n` characters, the query value lands verbatim in the `GET
Affected code
The vulnerability resides in `hackney_url:make_url/3` in `src/hackney_url.erl`, which concatenates the path and query binaries without percent-encoding `\r` or `\n` characters. The request target is then passed verbatim into the HTTP/1.1 request line at every `hackney_conn` entry point (`request`, `request_streaming`, `send_request_headers`, `request_async`) [patch_id=2473703]. No validation or escaping of CR/LF bytes existed before the fix.
What the fix does
The patch introduces `valid_request_target/1` in `src/hackney_conn.erl`, which checks the request target binary for raw `\r`, `\n`, or NUL bytes using `binary:match/2` [patch_id=2473703]. This guard is inserted at every public entry point (`request`, `request_streaming`, `send_request_headers`, `request_async`) before the request is dispatched to the state machine. If illegal bytes are found, the call returns `{error, {invalid_request_target, _}}` and no bytes reach the socket, preventing header injection and request splitting.
Preconditions
- inputAttacker must control all or part of a URL passed to hackney (e.g., a query parameter value)
- configNo prior sanitization of the URL by the calling application
Reproduction
1. Listen on a raw TCP port: `nc -lvnp 8080`. 2. Issue: `:hackney.get("http://127.0.0.1:8080/?q=x HTTP/1.1\r\nX-Injected: yes\r\nX:")`. 3. Observe the listener receives `X-Injected: yes` as a standalone header line in the request [ref_id=2].
Generated on May 25, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/benoitc/hackney/commit/ca73dd0aba0ed557449c18288bf07241671a43c9mitrepatch
- github.com/benoitc/hackney/security/advisories/GHSA-j9wq-vxxc-94wfmitrevendor-advisoryrelated
- cna.erlef.org/cves/CVE-2026-47075.htmlmitrerelated
- osv.dev/vulnerability/EEF-CVE-2026-47075mitrerelated
News mentions
0No linked articles in our index yet.