diff --git a/packages/playground/php-cors-proxy/README.md b/packages/playground/php-cors-proxy/README.md index aa5fc3ca51..5cec348506 100644 --- a/packages/playground/php-cors-proxy/README.md +++ b/packages/playground/php-cors-proxy/README.md @@ -25,6 +25,7 @@ Request http://127.0.0.1:5263/proxy.php/https://w.org/?test=1 to get the respons - Stream data both ways, don't buffer. - Don't pass auth headers in either direction. + - Opt-in for request headers possible using `X-Cors-Proxy-Allowed-Request-Headers`. - Refuse to request private IPs. - Refuse to process non-GET non-POST non-OPTIONS requests. - Refuse to process POST request body larger than, say, 100KB. diff --git a/packages/playground/php-cors-proxy/cors-proxy-functions.php b/packages/playground/php-cors-proxy/cors-proxy-functions.php index 894dff7939..4dacf92fd0 100644 --- a/packages/playground/php-cors-proxy/cors-proxy-functions.php +++ b/packages/playground/php-cors-proxy/cors-proxy-functions.php @@ -299,20 +299,67 @@ private static function ipv6InRange($ip, $start, $end) } +/** + * Filters headers by name, removing disallowed headers and enforcing opt-in requirements. + * + * @param array $php_headers { + * An associative array of headers. + * @type string $key Header name. + * } + * @param array $disallowed_headers List of header names that are disallowed. + * @param array $headers_requiring_opt_in List of header names that require opt-in + * via the X-Cors-Proxy-Allowed-Request-Headers header. + * + * @return array { + * Filtered headers. + * @type string $key Header name. + */ +function filter_headers_by_name( + $php_headers, + $disallowed_headers, + $headers_requiring_opt_in = [], +) { + $lowercased_php_headers = array_change_key_case($php_headers, CASE_LOWER); + $disallowed_headers = array_map('strtolower', $disallowed_headers); + $headers_requiring_opt_in = array_map('strtolower', $headers_requiring_opt_in); + + // Get explicitly allowed headers from X-Cors-Proxy-Allowed-Request-Headers + $headers_opt_in_str = + $lowercased_php_headers['x-cors-proxy-allowed-request-headers'] ?? ''; + $headers_with_opt_in = $headers_opt_in_str + ? array_map('trim', explode(',', $headers_opt_in_str)) + : []; + $headers_with_opt_in = array_map('strtolower', $headers_with_opt_in); + + // Filter headers + return array_filter( + $php_headers, + function ( + $key + ) use ( + $disallowed_headers, + $headers_requiring_opt_in, + $headers_with_opt_in, + ) { + $lower_key = strtolower($key); + + // Skip if disallowed + if (in_array($lower_key, $disallowed_headers)) { + return false; + } -function filter_headers_strings($php_headers, $remove_headers) { - $remove_headers = array_map('strtolower', $remove_headers); - $headers = []; - foreach ($php_headers as $header) { - $lower_header = strtolower($header); - foreach($remove_headers as $remove_header) { - if (strpos($lower_header, $remove_header) === 0) { - continue 2; + // Skip if opt-in is required but not provided + if ( + in_array($lower_key, $headers_requiring_opt_in) && + !in_array($lower_key, $headers_with_opt_in) + ) { + return false; } - } - $headers[] = $header; - } - return $headers; + + return true; + }, + ARRAY_FILTER_USE_KEY + ); } function kv_headers_to_curl_format($headers) { diff --git a/packages/playground/php-cors-proxy/cors-proxy.php b/packages/playground/php-cors-proxy/cors-proxy.php index 41b3fc27dc..62d9bd3a9a 100644 --- a/packages/playground/php-cors-proxy/cors-proxy.php +++ b/packages/playground/php-cors-proxy/cors-proxy.php @@ -101,14 +101,27 @@ "$host:443:$resolvedIp" ]); -// Pass all incoming headers except cookies and authorization -$curlHeaders = filter_headers_strings( - kv_headers_to_curl_format(getallheaders()), - [ - 'Cookie', - 'Authorization', - 'Host', - ] +$strictly_disallowed_headers = [ + // Cookies represent a relationship between the proxy server + // and the client, so it is inappropriate to forward them. + 'Cookie', + // Drop the incoming Host header because it identifies the + // proxy server, not the target server. + 'Host' +]; +$headers_requiring_opt_in = [ + // Allow Authorization header to be forwarded only if the client + // explicitly opts in to avoid undesirable situations such as: + // - a browser auto-sending basic auth with every proxy request + // - the proxy forwarding the basic auth values to all target servers + 'Authorization' +]; +$curlHeaders = kv_headers_to_curl_format( + filter_headers_by_name( + getallheaders(), + $strictly_disallowed_headers, + $headers_requiring_opt_in, + ) ); curl_setopt( $ch, @@ -173,6 +186,11 @@ function( stripos($header, 'HTTP/') !== 0 && stripos($header, 'Set-Cookie:') !== 0 && stripos($header, 'Authorization:') !== 0 && + // The proxy server does not support relaying auth challenges. + // Specifically, we want to avoid browsers prompting for basic auth + // credentials which they will send to the proxy server for the + // remainder of the session. + stripos($header, 'WWW-Authenticate:') !== 0 && stripos($header, 'Cache-Control:') !== 0 && // The browser won't accept multiple values for these headers. stripos($header, 'Access-Control-Allow-Origin:') !== 0 && diff --git a/packages/playground/php-cors-proxy/tests/ProxyFunctionsTests.php b/packages/playground/php-cors-proxy/tests/ProxyFunctionsTests.php index bf91890358..fc99bb7148 100644 --- a/packages/playground/php-cors-proxy/tests/ProxyFunctionsTests.php +++ b/packages/playground/php-cors-proxy/tests/ProxyFunctionsTests.php @@ -147,4 +147,72 @@ public function testUrlValidateAndResolveWithTargetSelf() 'http://cors.playground.wordpress.net/cors-proxy.php?http://cors.playground.wordpress.net' ); } -} \ No newline at end of file + + public function testFilterHeadersStrings() + { + $original_headers = [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Cookie' => 'test=1', + 'Host' => 'example.com', + ]; + + $strictly_disallowed_headers = [ + 'Cookie', + 'Host', + ]; + + $headers_requiring_opt_in = [ + 'Authorization', + ]; + + $this->assertEquals( + [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + filter_headers_by_name( + $original_headers, + $strictly_disallowed_headers, + $headers_requiring_opt_in, + ) + ); + } + + public function testFilterHeaderStringsWithAdditionalAllowedHeaders() + { + $original_headers = [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Cookie' => 'test=1', + 'Host' => 'example.com', + 'Authorization' => 'Bearer 1234567890', + 'X-Authorization' => 'Bearer 1234567890', + 'X-Cors-Proxy-Allowed-Request-Headers' => 'Authorization', + ]; + + $strictly_disallowed_headers = [ + 'Cookie', + 'Host', + ]; + + $headers_requiring_opt_in = [ + 'Authorization', + ]; + + $this->assertEquals( + [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer 1234567890', + 'X-Authorization' => 'Bearer 1234567890', + 'X-Cors-Proxy-Allowed-Request-Headers' => 'Authorization', + ], + filter_headers_by_name( + $original_headers, + $strictly_disallowed_headers, + $headers_requiring_opt_in, + ) + ); + } +}