[PATCH v4] wget: percent-encode control characters and space in the request URL

Hayden Barnes via busybox <[email protected]> Mon, 15 Jun 2026 21:15:17 -0400
Newsgroups gmane.linux.busybox
Message-ID <[email protected]>
wget copies the URL path and query into the HTTP request line verbatim, so a
URL that contains a raw CR, LF, or other control byte can split the request
line and inject headers (CVE-2025-60876). A space has the same effect: it
breaks the "METHOD SP request-target SP HTTP/1.1" framing.

The patches proposed earlier on the list reject the whole URL and call
bb_error_msg_and_die. That stops the injection, but it changes behavior. A URL
like http://example.org/foo bar used to be sent (the server gets the literal
space) and now fails outright, because the space is rejected along with the
control characters.

Handle the path and the host differently, matching how GNU wget and curl
behave:

- Path: percent-encode the offending octets in the request-target. Control
  bytes (0x00 through 0x1f), space (0x20), and DEL (0x7f) become %XX. Every
  other byte, including an existing '%', passes through unchanged, so an
  already-encoded path is not double-encoded and http://example.org/foo bar is
  sent as /foo%20bar. CR and LF can no longer reach the request line.

- Host: reject those same bytes. A hostname cannot legitimately contain control
  characters or a space, and percent-encoding is not defined for the authority
  component. This matters in proxy mode: the host is placed in the absolute-form
  request-target and the Host: header but is not resolved locally, so a raw CR
  or LF in the host would otherwise inject. GNU wget (since CVE-2017-6508) and
  curl reject control characters in the host as well.

The change is confined to networking/wget.c and adds one small helper. The
encoder reuses bb_hexdigits_upcase[] for the hex table and frees the request
target only under FEATURE_CLEAN_UP; these size refinements were proposed by
Roberto A. Foglietta.

Based on the analysis and patches from Takeuchi Yuma (2025-08) and Radoslav
Kolev (2025-11), changed from rejecting the whole URL to encoding the path and
rejecting only the host, to avoid the behavior regression raised in review.

Fixes CVE-2025-60876.

Suggested-by: Roberto A. Foglietta <[email protected]>
Signed-off-by: Hayden Barnes <[email protected]>
---
v4: fold in Roberto Foglietta's p2v2 size refinements - bb_hexdigits_upcase[]
for the hex table, the encode loop collapsed to a single store, and free the
request target only under FEATURE_CLEAN_UP. Kept the (const unsigned char *)
cast on the path pointer; dropping it (as p2v2 did) makes networking/wget.o fail
-Werror=pointer-sign. Verified clean: wget.o builds under CONFIG_WERROR=y and
testsuite/wget passes on current git master.

v3: encode the request-target path instead of rejecting it (so http://host/foo
bar still works, sent as /foo%20bar), which removes the behavior regression that
held the v1/v2 patches downstream. Reject control/space/DEL only in the host,
which a path-only encode would miss in proxy mode. v1/v2 rejected the whole URL.

Prior submissions: Kolev v2 [1], Takeuchi [2]; Alpine tracking [3].
[1] https://lists.busybox.net/pipermail/busybox/2025-November/091840.html
[2] https://lists.busybox.net/pipermail/busybox/2025-August/091710.html
[3] https://gitlab.alpinelinux.org/alpine/aports/-/work_items/17872

 networking/wget.c | 61 ++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 52 insertions(+), 9 deletions(-)

diff --git a/networking/wget.c b/networking/wget.c
index ec37677..4cc6212 100644
--- a/networking/wget.c
+++ b/networking/wget.c
@@ -622,6 +622,32 @@ static void parse_url(const char *src_url, struct host_info *h)
 	 */
 }
 
+/* RFC 3986: the request-target on the HTTP request line must not carry raw
+ * control characters or spaces - a crafted URL could otherwise split the
+ * request line and inject headers (CVE-2025-60876). Percent-encode such octets
+ * (controls, space, DEL) instead of sending them verbatim. '%' and other
+ * printable bytes pass through unchanged, so already-encoded sequences are not
+ * double-encoded and "/foo bar" is sent as "/foo%20bar", matching wget/curl. */
+static char *percent_encode_target(const char *path)
+{
+	const char *hex = bb_hexdigits_upcase;
+	const unsigned char *s = (const unsigned char *)path;
+	char *buf, *d;
+
+	d = buf = xmalloc(strlen(path) * 3 + 1);
+	while (*s) {
+		unsigned char c = *s++;
+		if (c <= ' ' || c == 0x7f) {
+			*d++ = '%';
+			*d++ = hex[c >> 4];
+			c = hex[c & 0xf];
+		}
+		*d++ = c;
+	}
+	*d = '\0';
+	return buf;
+}
+
 static char *get_sanitized_hdr(FILE *fp)
 {
 	char *s, *hdrval;
@@ -1220,15 +1246,32 @@ static void download_one_url(const char *url)
 		/* ssl (https) support is not configured */
 		sfp = open_socket(lsa);
 #endif
-		/* Send HTTP request */
-		if (use_proxy) {
-			SENDFMT(sfp, "GET %s://%s/%s HTTP/1.1\r\n",
-				target.protocol, target.host,
-				target.path);
-		} else {
-			SENDFMT(sfp, "%s /%s HTTP/1.1\r\n",
-				(option_mask32 & WGET_OPT_POST) ? "POST" : "GET",
-				target.path);
+		/* Send HTTP request. The request-target path is percent-encoded so a
+		 * crafted URL cannot split the request line or inject headers
+		 * (CVE-2025-60876): "/foo bar" is sent as "/foo%20bar". The host is sent
+		 * verbatim in the proxy request-target and the Host: header, and in proxy
+		 * mode is not resolved locally, so reject control chars and space there
+		 * (a hostname can never legitimately contain them). */
+		{
+			const unsigned char *hp = (const unsigned char *)target.host;
+			char *req_target;
+			while (*hp) {
+				if (*hp <= ' ' || *hp == 0x7f)
+					bb_simple_error_msg_and_die("bad character in URL host");
+				hp++;
+			}
+			req_target = percent_encode_target(target.path);
+			if (use_proxy) {
+				SENDFMT(sfp, "GET %s://%s/%s HTTP/1.1\r\n",
+					target.protocol, target.host,
+					req_target);
+			} else {
+				SENDFMT(sfp, "%s /%s HTTP/1.1\r\n",
+					(option_mask32 & WGET_OPT_POST) ? "POST" : "GET",
+					req_target);
+			}
+			if (ENABLE_FEATURE_CLEAN_UP)
+				free(req_target);
 		}
 		if (!USR_HEADER_HOST)
 			SENDFMT(sfp, "Host: %s\r\n", target.host);
-- 
2.54.0