From 8020788b1ad297e1754e088661237f17f37e4210 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Thu, 9 Jul 2026 09:36:18 +0000 Subject: [PATCH 1/4] Disallow newlines in CURLOPT_HTTPHEADER It was possible to have multiple lines in a HTTP headers in a cURL request, which then get interpreted as multiple headers. This commit changes that to throw a ValueError when using newlines or carriage returns in a string passed as header. --- ext/curl/interface.c | 76 ++++++++++--------- ext/curl/tests/curl_setopt_error_headers.phpt | 35 +++++++++ 2 files changed, 77 insertions(+), 34 deletions(-) create mode 100644 ext/curl/tests/curl_setopt_error_headers.phpt diff --git a/ext/curl/interface.c b/ext/curl/interface.c index 1dff14b7c8c2..98855b545309 100644 --- a/ext/curl/interface.c +++ b/ext/curl/interface.c @@ -2030,42 +2030,42 @@ static zend_result _php_curl_setopt(php_curl *ch, zend_long option, zval *zvalue HashTable *ph; zend_string *val, *tmp_val; struct curl_slist *slist = NULL; + const char *name = NULL; - if (Z_TYPE_P(zvalue) != IS_ARRAY) { - const char *name = NULL; - switch (option) { - case CURLOPT_HTTPHEADER: - name = "CURLOPT_HTTPHEADER"; - break; - case CURLOPT_QUOTE: - name = "CURLOPT_QUOTE"; - break; - case CURLOPT_HTTP200ALIASES: - name = "CURLOPT_HTTP200ALIASES"; - break; - case CURLOPT_POSTQUOTE: - name = "CURLOPT_POSTQUOTE"; - break; - case CURLOPT_PREQUOTE: - name = "CURLOPT_PREQUOTE"; - break; - case CURLOPT_TELNETOPTIONS: - name = "CURLOPT_TELNETOPTIONS"; - break; - case CURLOPT_MAIL_RCPT: - name = "CURLOPT_MAIL_RCPT"; - break; - case CURLOPT_RESOLVE: - name = "CURLOPT_RESOLVE"; - break; - case CURLOPT_PROXYHEADER: - name = "CURLOPT_PROXYHEADER"; - break; - case CURLOPT_CONNECT_TO: - name = "CURLOPT_CONNECT_TO"; - break; - } + switch (option) { + case CURLOPT_HTTPHEADER: + name = "CURLOPT_HTTPHEADER"; + break; + case CURLOPT_QUOTE: + name = "CURLOPT_QUOTE"; + break; + case CURLOPT_HTTP200ALIASES: + name = "CURLOPT_HTTP200ALIASES"; + break; + case CURLOPT_POSTQUOTE: + name = "CURLOPT_POSTQUOTE"; + break; + case CURLOPT_PREQUOTE: + name = "CURLOPT_PREQUOTE"; + break; + case CURLOPT_TELNETOPTIONS: + name = "CURLOPT_TELNETOPTIONS"; + break; + case CURLOPT_MAIL_RCPT: + name = "CURLOPT_MAIL_RCPT"; + break; + case CURLOPT_RESOLVE: + name = "CURLOPT_RESOLVE"; + break; + case CURLOPT_PROXYHEADER: + name = "CURLOPT_PROXYHEADER"; + break; + case CURLOPT_CONNECT_TO: + name = "CURLOPT_CONNECT_TO"; + break; + } + if (Z_TYPE_P(zvalue) != IS_ARRAY) { zend_type_error("%s(): The %s option must have an array value", get_active_function_name(), name); return FAILURE; } @@ -2074,6 +2074,14 @@ static zend_result _php_curl_setopt(php_curl *ch, zend_long option, zval *zvalue ZEND_HASH_FOREACH_VAL(ph, current) { ZVAL_DEREF(current); val = zval_get_tmp_string(current, &tmp_val); + + if ((option == CURLOPT_HTTPHEADER || option == CURLOPT_PROXYHEADER) && strpbrk(ZSTR_VAL(val), "\r\n")) { + curl_slist_free_all(slist); + zend_tmp_string_release(tmp_val); + zend_value_error("%s(): The %s option array cannot contain strings with newline characters", get_active_function_name(), name); + return FAILURE; + } + struct curl_slist *new_slist = curl_slist_append(slist, ZSTR_VAL(val)); zend_tmp_string_release(tmp_val); if (!new_slist) { diff --git a/ext/curl/tests/curl_setopt_error_headers.phpt b/ext/curl/tests/curl_setopt_error_headers.phpt new file mode 100644 index 000000000000..fc1246f1768c --- /dev/null +++ b/ext/curl/tests/curl_setopt_error_headers.phpt @@ -0,0 +1,35 @@ +--TEST-- +curl_setopt() throws ValueError for newlines in headers +--EXTENSIONS-- +curl +--FILE-- + ["Hello: world\nFoo: bar"], + "CR injection" => ["Hello: world\rFoo: bar"], + "CRLF injection" => ["Hello: world\r\nFoo: bar"] +]; + +foreach ($bad_headers as $type => $headers) { + echo "Testing $type:\n"; + try { + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + } catch (ValueError $exception) { + echo $exception->getMessage() . "\n\n"; + } +} + +$ch = null; +?> +--EXPECT-- +Testing LF injection: +curl_setopt(): The CURLOPT_HTTPHEADER option array cannot contain strings with newline characters + +Testing CR injection: +curl_setopt(): The CURLOPT_HTTPHEADER option array cannot contain strings with newline characters + +Testing CRLF injection: +curl_setopt(): The CURLOPT_HTTPHEADER option array cannot contain strings with newline characters From 1846f783a95a313ed2f82ac92720e81f57478804 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Thu, 9 Jul 2026 12:23:06 +0000 Subject: [PATCH 2/4] Change error message, use switch for readability Based on PR review comments. I'll do the NUL byte check separately. --- ext/curl/interface.c | 16 ++++++++++------ ext/curl/tests/curl_setopt_error_headers.phpt | 6 +++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/ext/curl/interface.c b/ext/curl/interface.c index 98855b545309..1376af07ef2c 100644 --- a/ext/curl/interface.c +++ b/ext/curl/interface.c @@ -2075,12 +2075,16 @@ static zend_result _php_curl_setopt(php_curl *ch, zend_long option, zval *zvalue ZVAL_DEREF(current); val = zval_get_tmp_string(current, &tmp_val); - if ((option == CURLOPT_HTTPHEADER || option == CURLOPT_PROXYHEADER) && strpbrk(ZSTR_VAL(val), "\r\n")) { - curl_slist_free_all(slist); - zend_tmp_string_release(tmp_val); - zend_value_error("%s(): The %s option array cannot contain strings with newline characters", get_active_function_name(), name); - return FAILURE; - } + switch (option) { + case CURLOPT_HTTPHEADER: + case CURLOPT_PROXYHEADER: + if (strpbrk(ZSTR_VAL(val), "\r\n")) { + curl_slist_free_all(slist); + zend_tmp_string_release(tmp_val); + zend_value_error("%s(): Header entries for the %s option may not contain more than a single header, new line detected", get_active_function_name(), name); + return FAILURE; + } + } struct curl_slist *new_slist = curl_slist_append(slist, ZSTR_VAL(val)); zend_tmp_string_release(tmp_val); diff --git a/ext/curl/tests/curl_setopt_error_headers.phpt b/ext/curl/tests/curl_setopt_error_headers.phpt index fc1246f1768c..4d692c63285d 100644 --- a/ext/curl/tests/curl_setopt_error_headers.phpt +++ b/ext/curl/tests/curl_setopt_error_headers.phpt @@ -26,10 +26,10 @@ $ch = null; ?> --EXPECT-- Testing LF injection: -curl_setopt(): The CURLOPT_HTTPHEADER option array cannot contain strings with newline characters +curl_setopt(): Header entries for the CURLOPT_HTTPHEADER option may not contain more than a single header, new line detected Testing CR injection: -curl_setopt(): The CURLOPT_HTTPHEADER option array cannot contain strings with newline characters +curl_setopt(): Header entries for the CURLOPT_HTTPHEADER option may not contain more than a single header, new line detected Testing CRLF injection: -curl_setopt(): The CURLOPT_HTTPHEADER option array cannot contain strings with newline characters +curl_setopt(): Header entries for the CURLOPT_HTTPHEADER option may not contain more than a single header, new line detected From 21fb5b8492383124bd2f544b712a1845001a6034 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Thu, 9 Jul 2026 12:38:48 +0000 Subject: [PATCH 3/4] Throw an error on NUL byte in arrays This is already done for string options in php_curl_option_str (see bug #68089). Similarly, array arguments should also not contain NUL bytes, so we throw the same error for these. --- ext/curl/interface.c | 7 +++++++ ext/curl/tests/curl_setopt_error_headers.phpt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ext/curl/interface.c b/ext/curl/interface.c index 1376af07ef2c..fef15814e9b4 100644 --- a/ext/curl/interface.c +++ b/ext/curl/interface.c @@ -2075,6 +2075,13 @@ static zend_result _php_curl_setopt(php_curl *ch, zend_long option, zval *zvalue ZVAL_DEREF(current); val = zval_get_tmp_string(current, &tmp_val); + if (zend_str_has_nul_byte(val)) { + curl_slist_free_all(slist); + zend_tmp_string_release(tmp_val); + zend_value_error("%s(): cURL option must not contain any null bytes", get_active_function_name()); + return FAILURE; + } + switch (option) { case CURLOPT_HTTPHEADER: case CURLOPT_PROXYHEADER: diff --git a/ext/curl/tests/curl_setopt_error_headers.phpt b/ext/curl/tests/curl_setopt_error_headers.phpt index 4d692c63285d..1008c571ee74 100644 --- a/ext/curl/tests/curl_setopt_error_headers.phpt +++ b/ext/curl/tests/curl_setopt_error_headers.phpt @@ -10,7 +10,7 @@ $ch = curl_init(); $bad_headers = [ "LF injection" => ["Hello: world\nFoo: bar"], "CR injection" => ["Hello: world\rFoo: bar"], - "CRLF injection" => ["Hello: world\r\nFoo: bar"] + "CRLF injection" => ["Hello: world\r\nFoo: bar"], ]; foreach ($bad_headers as $type => $headers) { From 770518055751ee9af2c69dd8d43a60185ff33e76 Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Thu, 9 Jul 2026 13:04:43 +0000 Subject: [PATCH 4/4] Include the name of the option in the error message And add test case --- ext/curl/interface.c | 2 +- .../tests/curl_setopt_error_nul_byte.phpt | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 ext/curl/tests/curl_setopt_error_nul_byte.phpt diff --git a/ext/curl/interface.c b/ext/curl/interface.c index fef15814e9b4..3506316cf741 100644 --- a/ext/curl/interface.c +++ b/ext/curl/interface.c @@ -2078,7 +2078,7 @@ static zend_result _php_curl_setopt(php_curl *ch, zend_long option, zval *zvalue if (zend_str_has_nul_byte(val)) { curl_slist_free_all(slist); zend_tmp_string_release(tmp_val); - zend_value_error("%s(): cURL option must not contain any null bytes", get_active_function_name()); + zend_value_error("%s(): cURL option %s must not contain any null bytes", get_active_function_name(), name); return FAILURE; } diff --git a/ext/curl/tests/curl_setopt_error_nul_byte.phpt b/ext/curl/tests/curl_setopt_error_nul_byte.phpt new file mode 100644 index 000000000000..23be973b82c2 --- /dev/null +++ b/ext/curl/tests/curl_setopt_error_nul_byte.phpt @@ -0,0 +1,52 @@ +--TEST-- +curl_setopt() throws ValueError for NUL bytes in lists +--EXTENSIONS-- +curl +--FILE-- +getMessage() . "\n\n"; + } +} + +$ch = null; +?> +--EXPECT-- +CURLOPT_HTTP200ALIASES: curl_setopt(): cURL option CURLOPT_HTTP200ALIASES must not contain any null bytes + +CURLOPT_HTTPHEADER: curl_setopt(): cURL option CURLOPT_HTTPHEADER must not contain any null bytes + +CURLOPT_POSTQUOTE: curl_setopt(): cURL option CURLOPT_POSTQUOTE must not contain any null bytes + +CURLOPT_PREQUOTE: curl_setopt(): cURL option CURLOPT_PREQUOTE must not contain any null bytes + +CURLOPT_QUOTE: curl_setopt(): cURL option CURLOPT_QUOTE must not contain any null bytes + +CURLOPT_TELNETOPTIONS: curl_setopt(): cURL option CURLOPT_TELNETOPTIONS must not contain any null bytes + +CURLOPT_MAIL_RCPT: curl_setopt(): cURL option CURLOPT_MAIL_RCPT must not contain any null bytes + +CURLOPT_RESOLVE: curl_setopt(): cURL option CURLOPT_RESOLVE must not contain any null bytes + +CURLOPT_PROXYHEADER: curl_setopt(): cURL option CURLOPT_PROXYHEADER must not contain any null bytes + +CURLOPT_CONNECT_TO: curl_setopt(): cURL option CURLOPT_CONNECT_TO must not contain any null bytes