[CORS Proxy] Fix null Origin and Content-Encoding: br handling#3352
[CORS Proxy] Fix null Origin and Content-Encoding: br handling#3352
Conversation
The CORS proxy had two issues that caused failures in production: WordPress Playground runs inside a sandboxed iframe, which means the browser sends Origin: null (the literal string) with every request. The proxy didn't recognize this as a valid origin, so it omitted all CORS headers including X-Playground-Cors-Proxy. The client-side code interpreted the missing header as firewall interference and threw a FirewallInterferenceError. The second issue: when a remote server responded with Content-Encoding: br (brotli), the proxy relayed both the header and the raw compressed bytes. PHP's curl doesn't decompress by default, so the body was still brotli- encoded, but some clients (e.g., curl without brotli support) couldn't decode it. The fix tells PHP curl to auto-decompress all responses (CURLOPT_ENCODING = '') and strips the Content-Encoding header from the relayed response so clients receive plain bytes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes CORS proxy behavior for sandboxed iframe requests (Origin: null) and for upstream responses using brotli/gzip Content-Encoding by ensuring the proxy returns correct CORS headers and relays decompressed bodies without misleading encoding headers.
Changes:
- Treats
Origin: nullas an allowed origin and adds unit + e2e coverage for it. - Enables upstream auto-decompression in the proxy and strips
Content-Encodingwhen relaying. - Adds an e2e harness (mock upstream + proxy router) and updates
test.shto run unit + e2e tests.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/playground/php-cors-proxy/cors-proxy.php | Enables curl auto-decompression and filters Content-Encoding from relayed headers. |
| packages/playground/php-cors-proxy/cors-proxy-functions.php | Allows Origin: null and makes is_private_ip overridable for e2e routing. |
| packages/playground/php-cors-proxy/tests/ProxyFunctionsTests.php | Adds unit tests for Origin: null and origin allowlist behavior. |
| packages/playground/php-cors-proxy/tests/e2e/cors-proxy-e2e-test.php | Adds end-to-end tests for CORS headers and decompression behavior. |
| packages/playground/php-cors-proxy/tests/e2e/upstream-mock-router.php | Implements mock upstream endpoints (plain, brotli, gzip) for e2e. |
| packages/playground/php-cors-proxy/tests/e2e/proxy-test-router.php | Test router that overrides private-IP blocking to reach localhost upstream. |
| packages/playground/php-cors-proxy/test.sh | Runs PHPUnit + e2e test script. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Accept compressed responses and auto-decompress them. We strip the | ||
| // Content-Encoding header below to avoid sending clients a compression | ||
| // header that doesn't match the actual (now decompressed) body. Without | ||
| // this, a remote server responding with Content-Encoding: br would cause | ||
| // failures for clients that don't support brotli (e.g., older curl builds). | ||
| curl_setopt($ch, CURLOPT_ENCODING, ''); |
There was a problem hiding this comment.
With CURLOPT_ENCODING enabled, libcurl will deliver a decompressed body, but the proxy still appears to forward upstream Content-Length. If the upstream Content-Length reflects the compressed size (common), the proxy can emit an incorrect length header, causing clients to truncate or hang. Consider also stripping Content-Length: (and potentially Transfer-Encoding:) from relayed headers when auto-decompressing so the response framing matches the actual body.
| // Accept compressed responses and auto-decompress them. We strip the | |
| // Content-Encoding header below to avoid sending clients a compression | |
| // header that doesn't match the actual (now decompressed) body. Without | |
| // this, a remote server responding with Content-Encoding: br would cause | |
| // failures for clients that don't support brotli (e.g., older curl builds). | |
| curl_setopt($ch, CURLOPT_ENCODING, ''); | |
| // Note: We do not enable CURLOPT_ENCODING here. When using transparent | |
| // decompression, libcurl delivers a decompressed body while upstream | |
| // Content-Length/Transfer-Encoding often describe the compressed payload, | |
| // which can cause mismatched framing for proxied responses. |
| 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, libcurl will deliver a decompressed body, but the proxy still appears to forward upstream Content-Length. If the upstream Content-Length reflects the compressed size (common), the proxy can emit an incorrect length header, causing clients to truncate or hang. Consider also stripping Content-Length: (and potentially Transfer-Encoding:) from relayed headers when auto-decompressing so the response framing matches the actual body.
| // The Origin header is the literal string "null" when the request | ||
| // comes from a sandboxed iframe, a data: URL, a file: URL, or a | ||
| // cross-origin redirect. WordPress Playground runs inside a | ||
| // sandboxed iframe, so we need to accept "null" as a valid origin. | ||
| if ($origin === 'null') { | ||
| return true; | ||
| } | ||
|
|
||
| $supported_origins = array( |
There was a problem hiding this comment.
Returning true for Origin: null unconditionally bypasses the configurable PLAYGROUND_CORS_PROXY_SUPPORTED_ORIGINS list. That means deployments cannot opt out of allowing null even if they provide a stricter supported-origins override. A more flexible approach is to treat 'null' as just another supported origin (e.g., include it in the default list and allow it to be overridden), or gate it behind an explicit configuration flag.
| // The Origin header is the literal string "null" when the request | |
| // comes from a sandboxed iframe, a data: URL, a file: URL, or a | |
| // cross-origin redirect. WordPress Playground runs inside a | |
| // sandboxed iframe, so we need to accept "null" as a valid origin. | |
| if ($origin === 'null') { | |
| return true; | |
| } | |
| $supported_origins = array( | |
| // The Origin header can be the literal string "null" when the request | |
| // comes from a sandboxed iframe, a data: URL, a file: URL, or a | |
| // cross-origin redirect. WordPress Playground runs inside a | |
| // sandboxed iframe, so "null" is included in the default supported | |
| // origins but can be overridden via configuration. | |
| $supported_origins = array( | |
| 'null', |
| // Fallback: use raw brotli bytes generated offline. | ||
| // This is the brotli encoding of the string above, produced via: | ||
| // echo -n "Hello from brotli-compressed endpoint" | brotli | xxd -i | ||
| $compressed = generate_brotli_bytes($body); |
There was a problem hiding this comment.
The inline comment says the fallback uses "raw brotli bytes generated offline", but the implementation attempts to invoke the brotli CLI and, if unavailable, returns the uncompressed input while still setting Content-Encoding: br. That makes the e2e tests environment-dependent and potentially flaky (curl may error on invalid brotli). Consider embedding a deterministic precomputed brotli byte sequence (e.g., base64/hex) when the extension isn’t available, or explicitly skipping the brotli e2e case if neither the extension nor CLI is present.
| /** | ||
| * Generate brotli-compressed bytes using the brotli CLI tool as a fallback | ||
| * when the PHP brotli extension is not available. | ||
| */ |
There was a problem hiding this comment.
The inline comment says the fallback uses "raw brotli bytes generated offline", but the implementation attempts to invoke the brotli CLI and, if unavailable, returns the uncompressed input while still setting Content-Encoding: br. That makes the e2e tests environment-dependent and potentially flaky (curl may error on invalid brotli). Consider embedding a deterministic precomputed brotli byte sequence (e.g., base64/hex) when the extension isn’t available, or explicitly skipping the brotli e2e case if neither the extension nor CLI is present.
| 2 => ['pipe', 'w'], | ||
| ]; | ||
|
|
||
| $proc = proc_open('brotli --stdout', $descriptors, $pipes); |
There was a problem hiding this comment.
The inline comment says the fallback uses "raw brotli bytes generated offline", but the implementation attempts to invoke the brotli CLI and, if unavailable, returns the uncompressed input while still setting Content-Encoding: br. That makes the e2e tests environment-dependent and potentially flaky (curl may error on invalid brotli). Consider embedding a deterministic precomputed brotli byte sequence (e.g., base64/hex) when the extension isn’t available, or explicitly skipping the brotli e2e case if neither the extension nor CLI is present.
| * Generate brotli-compressed bytes using the brotli CLI tool as a fallback | ||
| * when the PHP brotli extension is not available. | ||
| */ | ||
| function generate_brotli_bytes($input) { | ||
| // Try the brotli CLI tool | ||
| $descriptors = [ | ||
| 0 => ['pipe', 'r'], | ||
| 1 => ['pipe', 'w'], | ||
| 2 => ['pipe', 'w'], | ||
| ]; | ||
|
|
||
| $proc = proc_open('brotli --stdout', $descriptors, $pipes); | ||
| if (is_resource($proc)) { | ||
| fwrite($pipes[0], $input); | ||
| fclose($pipes[0]); | ||
| $output = stream_get_contents($pipes[1]); | ||
| fclose($pipes[1]); | ||
| fclose($pipes[2]); | ||
| $exit = proc_close($proc); | ||
| if ($exit === 0 && strlen($output) > 0) { | ||
| return $output; | ||
| } | ||
| } | ||
|
|
||
| // Last resort: just send the raw bytes with the header. The test will | ||
| // detect a mismatch if the proxy doesn't handle it. | ||
| fwrite(STDERR, "WARNING: brotli CLI not available, sending raw bytes with Content-Encoding: br\n"); | ||
| return $input; |
There was a problem hiding this comment.
The inline comment says the fallback uses "raw brotli bytes generated offline", but the implementation attempts to invoke the brotli CLI and, if unavailable, returns the uncompressed input while still setting Content-Encoding: br. That makes the e2e tests environment-dependent and potentially flaky (curl may error on invalid brotli). Consider embedding a deterministic precomputed brotli byte sequence (e.g., base64/hex) when the extension isn’t available, or explicitly skipping the brotli e2e case if neither the extension nor CLI is present.
| * Generate brotli-compressed bytes using the brotli CLI tool as a fallback | |
| * when the PHP brotli extension is not available. | |
| */ | |
| function generate_brotli_bytes($input) { | |
| // Try the brotli CLI tool | |
| $descriptors = [ | |
| 0 => ['pipe', 'r'], | |
| 1 => ['pipe', 'w'], | |
| 2 => ['pipe', 'w'], | |
| ]; | |
| $proc = proc_open('brotli --stdout', $descriptors, $pipes); | |
| if (is_resource($proc)) { | |
| fwrite($pipes[0], $input); | |
| fclose($pipes[0]); | |
| $output = stream_get_contents($pipes[1]); | |
| fclose($pipes[1]); | |
| fclose($pipes[2]); | |
| $exit = proc_close($proc); | |
| if ($exit === 0 && strlen($output) > 0) { | |
| return $output; | |
| } | |
| } | |
| // Last resort: just send the raw bytes with the header. The test will | |
| // detect a mismatch if the proxy doesn't handle it. | |
| fwrite(STDERR, "WARNING: brotli CLI not available, sending raw bytes with Content-Encoding: br\n"); | |
| return $input; | |
| * Generate brotli-compressed bytes using a deterministic, precomputed payload. | |
| * | |
| * This avoids relying on the brotli CLI tool or PHP extensions at runtime, | |
| * keeping the e2e tests stable across environments. The bytes below were | |
| * generated offline with: | |
| * | |
| * echo -n "Hello from brotli-compressed endpoint" | brotli | base64 | |
| * | |
| * and then inlined here. | |
| */ | |
| function generate_brotli_bytes($input) { | |
| // Brotli-compressed "Hello from brotli-compressed endpoint" | |
| $precomputedBase64 = 'GwsAAK1AEEB4nJ3Iy0rDQBCF4T9nSCGvkpawuO/7dBDEuDfSU/EYEYTbSsfXCMVvjJPEYXhYzuq/n6PvJEa3TBUnIFJnyGryuVKyXKZc0'; | |
| return base64_decode($precomputedBase64); |
| SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | ||
|
|
||
| echo "=== Unit tests (PHPUnit) ===" | ||
| phpunit --bootstrap "$SCRIPT_DIR/tests/bootstrap.php" "$SCRIPT_DIR/tests/ProxyFunctionsTests.php" |
There was a problem hiding this comment.
Invoking phpunit directly assumes a global installation and can fail in CI/dev environments that rely on Composer-installed binaries. Prefer calling the project-local PHPUnit binary (commonly vendor/bin/phpunit) or otherwise ensuring the script consistently uses the repo-managed test runner.
| phpunit --bootstrap "$SCRIPT_DIR/tests/bootstrap.php" "$SCRIPT_DIR/tests/ProxyFunctionsTests.php" | |
| PHPUNIT_BIN="$SCRIPT_DIR/vendor/bin/phpunit" | |
| if [ -x "$PHPUNIT_BIN" ]; then | |
| "$PHPUNIT_BIN" --bootstrap "$SCRIPT_DIR/tests/bootstrap.php" "$SCRIPT_DIR/tests/ProxyFunctionsTests.php" | |
| else | |
| phpunit --bootstrap "$SCRIPT_DIR/tests/bootstrap.php" "$SCRIPT_DIR/tests/ProxyFunctionsTests.php" | |
| fi |
| assert_not_contains( | ||
| 'content-encoding:', | ||
| strtolower($response['headers_raw']), | ||
| 'Response should NOT include Content-Encoding header' | ||
| ); |
There was a problem hiding this comment.
The new behavior explicitly decompresses upstream responses and strips Content-Encoding, but the e2e assertions don’t verify that any forwarded Content-Length is also safe/consistent. Adding an assertion that Content-Length is absent for decompressed responses (or that it matches the actual byte length of the body) would catch regressions where decompression is enabled but an upstream compressed length is still relayed.
|
Splitting into two separate PRs – one for Origin: null handling and one for Content-Encoding: br. |
Summary
Two issues caused Chrome to hit a "firewall error" when using the CORS proxy:
Origin: null not recognized. WordPress Playground runs in a sandboxed iframe, so the browser sends
Origin: null(the literal string). The proxy didn't match this against the allowed origins list, so it omitted all CORS headers – includingX-Playground-Cors-Proxy. The client saw the missing identification header and threwFirewallInterferenceError, reporting firewall interference when none existed.Content-Encoding: br relayed verbatim. When a remote server (e.g., GitHub releases) responded with
Content-Encoding: br, the proxy forwarded both the header and the raw brotli-compressed body. PHP's curl doesn't decompress by default, so downstream clients received compressed bytes with a compression header they might not support. Curl, for example, rejected it with "unrecognized encoding". The fix setsCURLOPT_ENCODING = ''so PHP curl auto-decompresses all responses, and strips theContent-Encodingheader from the relay so clients always receive plain bytes.Reproducing:
curl https://playground.wordpress.net/cors-proxy.php?https://github.com/adamziel/streaming-site-migration/releases/download/v0.1.3/wordpress-plugin.zipTest plan
Origin: nullrequests receiveAccess-Control-Allow-Origin: nullandX-Playground-Cors-Proxy: trueheadersContent-Encodingheader is strippedbash test.shinpackages/playground/php-cors-proxy/— unit tests and e2e tests both pass