close
Skip to content

[CORS Proxy] Strip Content-Encoding after auto-decompressing responses#3354

Closed
adamziel wants to merge 2 commits intotrunkfrom
fix/cors-proxy-content-encoding
Closed

[CORS Proxy] Strip Content-Encoding after auto-decompressing responses#3354
adamziel wants to merge 2 commits intotrunkfrom
fix/cors-proxy-content-encoding

Conversation

@adamziel
Copy link
Collaborator

@adamziel adamziel commented Mar 7, 2026

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 a Content-Encoding: br header they might not support. For instance, curl without 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 the Content-Encoding header 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 a Content-Encoding: br header.

Test plan

  • Verify brotli-compressed upstream responses are decompressed and Content-Encoding header is stripped
  • Verify gzip-compressed upstream responses are handled the same way
  • Verify uncompressed responses still pass through normally
  • Run bash test.sh in packages/playground/php-cors-proxy/ – all tests pass

adamziel and others added 2 commits March 7, 2026 09:40
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>
Copilot AI review requested due to automatic review settings March 7, 2026 08:42
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 relayed Content-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.

Comment on lines +17 to +24
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;
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +70
fwrite(STDERR, "WARNING: neither brotli PHP extension nor CLI available\n");
return $input;
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +257 to +263
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
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +231 to +233
$env = [
'PLAYGROUND_CORS_PROXY_DISABLE_RATE_LIMIT' => '1',
];
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$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',
]
);

Copilot uses AI. Check for mistakes.
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($cmd, $descriptors, $pipes, null, $env);
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +215 to +218
$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);
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$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);

Copilot uses AI. Check for mistakes.
// cors-proxy-functions.php wraps its definition in function_exists(), so
// defining it here first takes precedence.
function is_private_ip($ip) {
return false;
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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;

Copilot uses AI. Check for mistakes.
);
}

static public function providerShouldRespondWithCorsHeaders() {
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency with common PHP style (PSR-12), prefer public static function ... ordering instead of static public.

Copilot uses AI. Check for mistakes.
@adamziel
Copy link
Collaborator Author

adamziel commented Mar 7, 2026

Closing – the real fix belongs in the tcp-over-fetch bridge, not the CORS proxy.

@adamziel adamziel closed this Mar 7, 2026
@adamziel adamziel deleted the fix/cors-proxy-content-encoding branch March 7, 2026 09:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants