[CORS Proxy] Strip Content-Encoding after auto-decompressing responses#3354
[CORS Proxy] Strip Content-Encoding after auto-decompressing responses#3354
Conversation
WordPress Playground runs inside a sandboxed iframe, which makes the browser send `Origin: null` (the literal string) with every cross-origin request. The CORS proxy didn't recognize this as a valid origin, so it omitted all CORS response headers including X-Playground-Cors-Proxy. The client then threw FirewallInterferenceError, mistaking the missing header for network firewall interference. The fix adds 'null' to the default supported_origins list, alongside the existing localhost and playground.wordpress.net entries. This keeps it configurable – production deployments that override the list via PLAYGROUND_CORS_PROXY_SUPPORTED_ORIGINS must include 'null' explicitly. Also enables the existing test suite (test.sh was a no-op before) and adds e2e tests that start the actual PHP proxy server, send requests with various Origin headers, and verify CORS behavior end-to-end. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a remote server responds with Content-Encoding: br (brotli), the CORS proxy was relaying both the header and the raw compressed body. PHP's curl doesn't decompress by default, so the downstream client received brotli-encoded bytes alongside a Content-Encoding: br header. Clients without brotli support (e.g., older curl builds) rejected the response as an unrecognized encoding. The fix sets CURLOPT_ENCODING = '' so PHP curl auto-negotiates and decompresses all supported encodings (gzip, deflate, brotli), then strips the Content-Encoding header from the relayed response. Clients always receive plain, uncompressed bytes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Updates the PHP CORS proxy to auto-decompress upstream responses and avoid forwarding mismatched Content-Encoding, plus adds unit/e2e coverage and a runnable test script.
Changes:
- Enable libcurl auto-decompression (
CURLOPT_ENCODING = '') and strip relayedContent-Encoding. - Add e2e harness with a mock upstream server (plain/gzip/brotli) and proxy router for localhost.
- Expand PHPUnit coverage for CORS-origin logic and wire tests into
test.sh.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/playground/php-cors-proxy/cors-proxy.php | Enables auto-decompression and filters out Content-Encoding from relayed headers. |
| packages/playground/php-cors-proxy/cors-proxy-functions.php | Adds overridable is_private_ip() and expands supported origins to include literal null. |
| packages/playground/php-cors-proxy/tests/ProxyFunctionsTests.php | Adds data-provider unit tests for should_respond_with_cors_headers(). |
| packages/playground/php-cors-proxy/tests/e2e/cors-proxy-e2e-test.php | Adds end-to-end tests that spin up upstream + proxy servers and assert header/body behavior. |
| packages/playground/php-cors-proxy/tests/e2e/proxy-test-router.php | Test-only router that overrides private-IP detection to allow localhost upstream. |
| packages/playground/php-cors-proxy/tests/e2e/upstream-mock-router.php | Mock upstream endpoints for plain, brotli, and gzip responses. |
| packages/playground/php-cors-proxy/test.sh | Runs PHPUnit and the new e2e test script. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| case '/brotli-compressed': | ||
| $body = 'Hello from brotli-compressed endpoint'; | ||
| $compressed = brotli_compress_with_fallback($body); | ||
| header('Content-Type: text/plain'); | ||
| header('Content-Encoding: br'); | ||
| header('Content-Length: ' . strlen($compressed)); | ||
| echo $compressed; | ||
| break; |
There was a problem hiding this comment.
If brotli compression isn't available, brotli_compress_with_fallback() returns the uncompressed input, but the endpoint still sends Content-Encoding: br. That produces an invalid response (body is plain text but claims brotli), and can make e2e results misleading. Fix by only setting Content-Encoding: br when brotli compression actually succeeded; otherwise either (a) return a 500 from /brotli-compressed, or (b) serve uncompressed content and omit the Content-Encoding header (and update Content-Length accordingly).
| fwrite(STDERR, "WARNING: neither brotli PHP extension nor CLI available\n"); | ||
| return $input; |
There was a problem hiding this comment.
If brotli compression isn't available, brotli_compress_with_fallback() returns the uncompressed input, but the endpoint still sends Content-Encoding: br. That produces an invalid response (body is plain text but claims brotli), and can make e2e results misleading. Fix by only setting Content-Encoding: br when brotli compression actually succeeded; otherwise either (a) return a 500 from /brotli-compressed, or (b) serve uncompressed content and omit the Content-Encoding header (and update Content-Length accordingly).
| stripos($header, 'Upgrade-Insecure-Requests:') !== 0 && | ||
| // We set CURLOPT_ENCODING to auto-decompress responses, so the | ||
| // body we relay is always uncompressed. Passing through a | ||
| // Content-Encoding header would tell clients the body is still | ||
| // compressed, causing decode failures (e.g., curl rejecting | ||
| // an unrecognized "br" encoding). | ||
| stripos($header, 'Content-Encoding:') !== 0 |
There was a problem hiding this comment.
With CURLOPT_ENCODING enabled, the relayed body size may change after decompression. If the proxy still forwards an upstream Content-Length (often the compressed length), clients can truncate or hang waiting for bytes. Consider stripping Content-Length: in the same header filter when auto-decompression is enabled (or recompute it, if you buffer the whole body).
| $env = [ | ||
| 'PLAYGROUND_CORS_PROXY_DISABLE_RATE_LIMIT' => '1', | ||
| ]; |
There was a problem hiding this comment.
Passing $env to proc_open() can replace (not merge with) the process environment, which may drop PATH and cause php -S ... to fail to start in some environments/CI. Merge with the current environment (e.g., array_merge(getenv(), [...]) or $_ENV/$_SERVER) or avoid env_vars entirely by injecting the variable into the command invocation.
| $env = [ | |
| 'PLAYGROUND_CORS_PROXY_DISABLE_RATE_LIMIT' => '1', | |
| ]; | |
| $currentEnv = getenv(); | |
| if (!is_array($currentEnv)) { | |
| $currentEnv = []; | |
| } | |
| $env = array_merge( | |
| $currentEnv, | |
| [ | |
| 'PLAYGROUND_CORS_PROXY_DISABLE_RATE_LIMIT' => '1', | |
| ] | |
| ); |
| 1 => ['pipe', 'w'], | ||
| 2 => ['pipe', 'w'], | ||
| ]; | ||
| $proc = proc_open($cmd, $descriptors, $pipes, null, $env); |
There was a problem hiding this comment.
Passing $env to proc_open() can replace (not merge with) the process environment, which may drop PATH and cause php -S ... to fail to start in some environments/CI. Merge with the current environment (e.g., array_merge(getenv(), [...]) or $_ENV/$_SERVER) or avoid env_vars entirely by injecting the variable into the command invocation.
| $sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); | ||
| socket_bind($sock, '127.0.0.1', 0); | ||
| socket_getsockname($sock, $addr, $port); | ||
| socket_close($sock); |
There was a problem hiding this comment.
This helper requires the ext-sockets extension, which may not be enabled everywhere the tests run. To reduce test-environment dependencies, consider using stream_socket_server('tcp://127.0.0.1:0', ...) (available by default) to allocate an ephemeral port.
| $sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); | |
| socket_bind($sock, '127.0.0.1', 0); | |
| socket_getsockname($sock, $addr, $port); | |
| socket_close($sock); | |
| $server = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); | |
| if ($server === false) { | |
| echo "Failed to find free port: $errstr ($errno)\n"; | |
| exit(1); | |
| } | |
| $name = stream_socket_get_name($server, false); | |
| if ($name === false) { | |
| fclose($server); | |
| echo "Failed to get socket name for free port\n"; | |
| exit(1); | |
| } | |
| fclose($server); | |
| $parts = explode(':', $name); | |
| $port = (int) array_pop($parts); |
| // cors-proxy-functions.php wraps its definition in function_exists(), so | ||
| // defining it here first takes precedence. | ||
| function is_private_ip($ip) { | ||
| return false; |
There was a problem hiding this comment.
Returning false for all inputs disables private-IP protections entirely for the e2e proxy process, which can mask SSRF-related behavior in tests and makes the test harness more permissive than necessary. Tighten the override to only allow localhost targets used by the tests (e.g., treat 127.0.0.1/::1 as non-private but keep other private ranges blocked).
| return false; | |
| // Allow localhost for test upstreams, but keep other private/reserved IPs blocked. | |
| if ($ip === '127.0.0.1' || $ip === '::1') { | |
| return false; | |
| } | |
| // For all other addresses, treat private or reserved IPs as private. | |
| $isPrivateOrReserved = !filter_var( | |
| $ip, | |
| FILTER_VALIDATE_IP, | |
| FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE | |
| ); | |
| return $isPrivateOrReserved; |
| ); | ||
| } | ||
|
|
||
| static public function providerShouldRespondWithCorsHeaders() { |
There was a problem hiding this comment.
For consistency with common PHP style (PSR-12), prefer public static function ... ordering instead of static public.
|
Closing – the real fix belongs in the tcp-over-fetch bridge, not the CORS proxy. |
Summary
Depends on #3353.
When a remote server (e.g., GitHub releases) responds with
Content-Encoding: br(brotli), the CORS proxy relayed both the header and the raw compressed bytes. PHP's curl doesn't decompress by default, so downstream clients received brotli-encoded bytes with aContent-Encoding: brheader they might not support. For instance,curlwithout brotli support rejected the response with "unrecognized encoding".The fix sets
CURLOPT_ENCODING = ''so PHP curl auto-negotiates and decompresses all supported encodings (gzip, deflate, brotli), then strips theContent-Encodingheader from the relayed response. Clients always receive plain, uncompressed bytes.Reproducing:
curl -v 'https://playground.wordpress.net/cors-proxy.php?https://github.com/adamziel/streaming-site-migration/releases/download/v0.1.3/wordpress-plugin.zip'— before the fix, this produced garbled brotli output with aContent-Encoding: brheader.Test plan
Content-Encodingheader is strippedbash test.shinpackages/playground/php-cors-proxy/– all tests pass