bug #32823 [HttpClient] Preserve the case of headers when sending them (nicolas-grekas)
This PR was merged into the 4.3 branch.
Discussion
----------
[HttpClient] Preserve the case of headers when sending them
| Q | A
| ------------- | ---
| Branch? | 4.3
| Bug fix? | yes
| New feature? | no
| BC breaks? | no
| Deprecations? | no
| Tests pass? | yes
| Fixed tickets | #32819
| License | MIT
| Doc PR | -
Some hosts are case sensitive, let's present headers in the submitted form.
Commits
-------
9ac85d5d8b
[HttpClient] Preserve the case of headers when sending them
This commit is contained in:
commit
9ac35529b6
@ -108,12 +108,14 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
|
||||
if ($pushedResponse = $this->multi->pushedResponses[$url] ?? null) {
|
||||
unset($this->multi->pushedResponses[$url]);
|
||||
// Accept pushed responses only if their headers related to authentication match the request
|
||||
$expectedHeaders = [
|
||||
$options['headers']['authorization'] ?? null,
|
||||
$options['headers']['cookie'] ?? null,
|
||||
$options['headers']['x-requested-with'] ?? null,
|
||||
$options['headers']['range'] ?? null,
|
||||
];
|
||||
$expectedHeaders = ['authorization', 'cookie', 'x-requested-with', 'range'];
|
||||
foreach ($expectedHeaders as $k => $v) {
|
||||
$expectedHeaders[$k] = null;
|
||||
|
||||
foreach ($options['normalized_headers'][$v] ?? [] as $h) {
|
||||
$expectedHeaders[$k][] = substr($h, 2 + \strlen($v));
|
||||
}
|
||||
}
|
||||
|
||||
if ('GET' === $method && $expectedHeaders === $pushedResponse->headers && !$options['body']) {
|
||||
$this->logger && $this->logger->debug(sprintf('Connecting request to pushed response: "%s %s"', $method, $url));
|
||||
@ -206,11 +208,11 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
|
||||
$curlopts[CURLOPT_NOSIGNAL] = true;
|
||||
}
|
||||
|
||||
if (!isset($options['headers']['accept-encoding'])) {
|
||||
if (!isset($options['normalized_headers']['accept-encoding'])) {
|
||||
$curlopts[CURLOPT_ENCODING] = ''; // Enable HTTP compression
|
||||
}
|
||||
|
||||
foreach ($options['request_headers'] as $header) {
|
||||
foreach ($options['headers'] as $header) {
|
||||
if (':' === $header[-2] && \strlen($header) - 2 === strpos($header, ': ')) {
|
||||
// curl requires a special syntax to send empty headers
|
||||
$curlopts[CURLOPT_HTTPHEADER][] = substr_replace($header, ';', -2);
|
||||
@ -221,7 +223,7 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
|
||||
|
||||
// Prevent curl from sending its default Accept and Expect headers
|
||||
foreach (['accept', 'expect'] as $header) {
|
||||
if (!isset($options['headers'][$header])) {
|
||||
if (!isset($options['normalized_headers'][$header])) {
|
||||
$curlopts[CURLOPT_HTTPHEADER][] = $header.':';
|
||||
}
|
||||
}
|
||||
@ -237,9 +239,9 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($options['headers']['content-length'][0])) {
|
||||
$curlopts[CURLOPT_INFILESIZE] = $options['headers']['content-length'][0];
|
||||
} elseif (!isset($options['headers']['transfer-encoding'])) {
|
||||
if (isset($options['normalized_headers']['content-length'][0])) {
|
||||
$curlopts[CURLOPT_INFILESIZE] = substr($options['normalized_headers']['content-length'][0], \strlen('Content-Length: '));
|
||||
} elseif (!isset($options['normalized_headers']['transfer-encoding'])) {
|
||||
$curlopts[CURLOPT_HTTPHEADER][] = 'Transfer-Encoding: chunked'; // Enable chunked request bodies
|
||||
}
|
||||
|
||||
@ -387,12 +389,12 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
|
||||
$redirectHeaders = [];
|
||||
if (0 < $options['max_redirects']) {
|
||||
$redirectHeaders['host'] = $host;
|
||||
$redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['request_headers'], static function ($h) {
|
||||
$redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) {
|
||||
return 0 !== stripos($h, 'Host:');
|
||||
});
|
||||
|
||||
if (isset($options['headers']['authorization']) || isset($options['headers']['cookie'])) {
|
||||
$redirectHeaders['no_auth'] = array_filter($options['request_headers'], static function ($h) {
|
||||
if (isset($options['normalized_headers']['authorization']) || isset($options['normalized_headers']['cookie'])) {
|
||||
$redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) {
|
||||
return 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:');
|
||||
});
|
||||
}
|
||||
|
@ -50,7 +50,10 @@ trait HttpClientTrait
|
||||
}
|
||||
$options['body'] = self::jsonEncode($options['json']);
|
||||
unset($options['json']);
|
||||
$options['headers']['content-type'] = $options['headers']['content-type'] ?? ['application/json'];
|
||||
|
||||
if (!isset($options['normalized_headers']['content-type'])) {
|
||||
$options['normalized_headers']['content-type'] = [$options['headers'][] = 'Content-Type: application/json'];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($options['body'])) {
|
||||
@ -61,19 +64,6 @@ trait HttpClientTrait
|
||||
$options['peer_fingerprint'] = self::normalizePeerFingerprint($options['peer_fingerprint']);
|
||||
}
|
||||
|
||||
// Compute request headers
|
||||
$requestHeaders = $headers = [];
|
||||
|
||||
foreach ($options['headers'] as $name => $values) {
|
||||
foreach ($values as $value) {
|
||||
$requestHeaders[] = $name.': '.$headers[$name][] = $value = (string) $value;
|
||||
|
||||
if (\strlen($value) !== strcspn($value, "\r\n\0")) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid header value: CR/LF/NUL found in "%s".', $value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate on_progress
|
||||
if (!\is_callable($onProgress = $options['on_progress'] ?? 'var_dump')) {
|
||||
throw new InvalidArgumentException(sprintf('Option "on_progress" must be callable, %s given.', \is_object($onProgress) ? \get_class($onProgress) : \gettype($onProgress)));
|
||||
@ -102,15 +92,14 @@ trait HttpClientTrait
|
||||
|
||||
if (null !== $url) {
|
||||
// Merge auth with headers
|
||||
if (($options['auth_basic'] ?? false) && !($headers['authorization'] ?? false)) {
|
||||
$requestHeaders[] = 'authorization: '.$headers['authorization'][] = 'Basic '.base64_encode($options['auth_basic']);
|
||||
if (($options['auth_basic'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) {
|
||||
$options['normalized_headers']['authorization'] = [$options['headers'][] = 'Authorization: Basic '.base64_encode($options['auth_basic'])];
|
||||
}
|
||||
// Merge bearer with headers
|
||||
if (($options['auth_bearer'] ?? false) && !($headers['authorization'] ?? false)) {
|
||||
$requestHeaders[] = 'authorization: '.$headers['authorization'][] = 'Bearer '.$options['auth_bearer'];
|
||||
if (($options['auth_bearer'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) {
|
||||
$options['normalized_headers']['authorization'] = [$options['headers'][] = 'Authorization: Bearer '.$options['auth_bearer']];
|
||||
}
|
||||
|
||||
$options['request_headers'] = $requestHeaders;
|
||||
unset($options['auth_basic'], $options['auth_bearer']);
|
||||
|
||||
// Parse base URI
|
||||
@ -124,7 +113,6 @@ trait HttpClientTrait
|
||||
}
|
||||
|
||||
// Finalize normalization of options
|
||||
$options['headers'] = $headers;
|
||||
$options['http_version'] = (string) ($options['http_version'] ?? '') ?: null;
|
||||
$options['timeout'] = (float) ($options['timeout'] ?? ini_get('default_socket_timeout'));
|
||||
|
||||
@ -136,31 +124,38 @@ trait HttpClientTrait
|
||||
*/
|
||||
private static function mergeDefaultOptions(array $options, array $defaultOptions, bool $allowExtraOptions = false): array
|
||||
{
|
||||
unset($options['request_headers'], $defaultOptions['request_headers']);
|
||||
|
||||
$options['headers'] = self::normalizeHeaders($options['headers'] ?? []);
|
||||
$options['normalized_headers'] = self::normalizeHeaders($options['headers'] ?? []);
|
||||
|
||||
if ($defaultOptions['headers'] ?? false) {
|
||||
$options['headers'] += self::normalizeHeaders($defaultOptions['headers']);
|
||||
$options['normalized_headers'] += self::normalizeHeaders($defaultOptions['headers']);
|
||||
}
|
||||
|
||||
if ($options['resolve'] ?? false) {
|
||||
$options['resolve'] = array_change_key_case($options['resolve']);
|
||||
$options['headers'] = array_merge(...array_values($options['normalized_headers']) ?: [[]]);
|
||||
|
||||
if ($resolve = $options['resolve'] ?? false) {
|
||||
$options['resolve'] = [];
|
||||
foreach ($resolve as $k => $v) {
|
||||
$options['resolve'][substr(self::parseUrl('http://'.$k)['authority'], 2)] = (string) $v;
|
||||
}
|
||||
}
|
||||
|
||||
// Option "query" is never inherited from defaults
|
||||
$options['query'] = $options['query'] ?? [];
|
||||
|
||||
foreach ($defaultOptions as $k => $v) {
|
||||
$options[$k] = $options[$k] ?? $v;
|
||||
if ('normalized_headers' !== $k && !isset($options[$k])) {
|
||||
$options[$k] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($defaultOptions['extra'])) {
|
||||
$options['extra'] += $defaultOptions['extra'];
|
||||
}
|
||||
|
||||
if ($defaultOptions['resolve'] ?? false) {
|
||||
$options['resolve'] += array_change_key_case($defaultOptions['resolve']);
|
||||
if ($resolve = $defaultOptions['resolve'] ?? false) {
|
||||
foreach ($resolve as $k => $v) {
|
||||
$options['resolve'] += [substr(self::parseUrl('http://'.$k)['authority'], 2) => (string) $v];
|
||||
}
|
||||
}
|
||||
|
||||
if ($allowExtraOptions || !$defaultOptions) {
|
||||
@ -169,7 +164,7 @@ trait HttpClientTrait
|
||||
|
||||
// Look for unsupported options
|
||||
foreach ($options as $name => $v) {
|
||||
if (\array_key_exists($name, $defaultOptions)) {
|
||||
if (\array_key_exists($name, $defaultOptions) || 'normalized_headers' === $name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -188,9 +183,9 @@ trait HttpClientTrait
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes headers by putting their names as lowercased keys.
|
||||
*
|
||||
* @return string[][]
|
||||
*
|
||||
* @throws InvalidArgumentException When an invalid header is found
|
||||
*/
|
||||
private static function normalizeHeaders(array $headers): array
|
||||
{
|
||||
@ -204,10 +199,15 @@ trait HttpClientTrait
|
||||
$values = (array) $values;
|
||||
}
|
||||
|
||||
$normalizedHeaders[$name = strtolower($name)] = [];
|
||||
$lcName = strtolower($name);
|
||||
$normalizedHeaders[$lcName] = [];
|
||||
|
||||
foreach ($values as $value) {
|
||||
$normalizedHeaders[$name][] = $value;
|
||||
$normalizedHeaders[$lcName][] = $value = $name.': '.$value;
|
||||
|
||||
if (\strlen($value) !== strcspn($value, "\r\n\0")) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid header: CR/LF/NUL found in "%s".', $value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -73,13 +73,13 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
|
||||
|
||||
$options['body'] = self::getBodyAsString($options['body']);
|
||||
|
||||
if ('' !== $options['body'] && 'POST' === $method && !isset($options['headers']['content-type'])) {
|
||||
$options['request_headers'][] = 'content-type: application/x-www-form-urlencoded';
|
||||
if ('' !== $options['body'] && 'POST' === $method && !isset($options['normalized_headers']['content-type'])) {
|
||||
$options['headers'][] = 'Content-Type: application/x-www-form-urlencoded';
|
||||
}
|
||||
|
||||
if ($gzipEnabled = \extension_loaded('zlib') && !isset($options['headers']['accept-encoding'])) {
|
||||
if ($gzipEnabled = \extension_loaded('zlib') && !isset($options['normalized_headers']['accept-encoding'])) {
|
||||
// gzip is the most widely available algo, no need to deal with deflate
|
||||
$options['request_headers'][] = 'accept-encoding: gzip';
|
||||
$options['headers'][] = 'Accept-Encoding: gzip';
|
||||
}
|
||||
|
||||
if ($options['peer_fingerprint']) {
|
||||
@ -160,12 +160,12 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
|
||||
|
||||
[$host, $port, $url['authority']] = self::dnsResolve($url, $this->multi, $info, $onProgress);
|
||||
|
||||
if (!isset($options['headers']['host'])) {
|
||||
$options['request_headers'][] = 'host: '.$host.$port;
|
||||
if (!isset($options['normalized_headers']['host'])) {
|
||||
$options['headers'][] = 'Host: '.$host.$port;
|
||||
}
|
||||
|
||||
if (!isset($options['headers']['user-agent'])) {
|
||||
$options['request_headers'][] = 'user-agent: Symfony HttpClient/Native';
|
||||
if (!isset($options['normalized_headers']['user-agent'])) {
|
||||
$options['headers'][] = 'User-Agent: Symfony HttpClient/Native';
|
||||
}
|
||||
|
||||
$context = [
|
||||
@ -208,7 +208,7 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
|
||||
|
||||
$resolveRedirect = self::createRedirectResolver($options, $host, $proxy, $noProxy, $info, $onProgress);
|
||||
$context = stream_context_create($context, ['notification' => $notification]);
|
||||
self::configureHeadersAndProxy($context, $host, $options['request_headers'], $proxy, $noProxy);
|
||||
self::configureHeadersAndProxy($context, $host, $options['headers'], $proxy, $noProxy);
|
||||
|
||||
return new NativeResponse($this->multi, $context, implode('', $url), $options, $gzipEnabled, $info, $resolveRedirect, $onProgress, $this->logger);
|
||||
}
|
||||
@ -335,12 +335,12 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
|
||||
$redirectHeaders = [];
|
||||
if (0 < $maxRedirects = $options['max_redirects']) {
|
||||
$redirectHeaders = ['host' => $host];
|
||||
$redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['request_headers'], static function ($h) {
|
||||
$redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) {
|
||||
return 0 !== stripos($h, 'Host:');
|
||||
});
|
||||
|
||||
if (isset($options['headers']['authorization']) || isset($options['headers']['cookie'])) {
|
||||
$redirectHeaders['no_auth'] = array_filter($options['request_headers'], static function ($h) {
|
||||
if (isset($options['normalized_headers']['authorization']) || isset($options['normalized_headers']['cookie'])) {
|
||||
$redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) {
|
||||
return 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:');
|
||||
});
|
||||
}
|
||||
@ -393,7 +393,7 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
|
||||
if (false !== (parse_url($location, PHP_URL_HOST) ?? false)) {
|
||||
// Authorization and Cookie headers MUST NOT follow except for the initial host name
|
||||
$requestHeaders = $redirectHeaders['host'] === $host ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
|
||||
$requestHeaders[] = 'host: '.$host.$port;
|
||||
$requestHeaders[] = 'Host: '.$host.$port;
|
||||
self::configureHeadersAndProxy($context, $host, $requestHeaders, $proxy, $noProxy);
|
||||
}
|
||||
|
||||
|
@ -241,6 +241,7 @@ final class NativeResponse implements ResponseInterface
|
||||
try {
|
||||
// Notify the progress callback so that it can e.g. cancel
|
||||
// the request if the stream is inactive for too long
|
||||
$info['total_time'] = microtime(true) - $info['start_time'];
|
||||
$onProgress();
|
||||
} catch (\Throwable $e) {
|
||||
// no-op
|
||||
|
@ -172,8 +172,8 @@ class HttpClientTraitTest extends TestCase
|
||||
public function testAuthBearerOption()
|
||||
{
|
||||
[, $options] = self::prepareRequest('POST', 'http://example.com', ['auth_bearer' => 'foobar'], HttpClientInterface::OPTIONS_DEFAULTS);
|
||||
$this->assertSame('Bearer foobar', $options['headers']['authorization'][0]);
|
||||
$this->assertSame('authorization: Bearer foobar', $options['request_headers'][0]);
|
||||
$this->assertSame(['Authorization: Bearer foobar'], $options['headers']);
|
||||
$this->assertSame(['Authorization: Bearer foobar'], $options['normalized_headers']['authorization']);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -226,7 +226,7 @@ class HttpClientTraitTest extends TestCase
|
||||
public function testPrepareAuthBasic($arg, $result)
|
||||
{
|
||||
[, $options] = $this->prepareRequest('POST', 'http://example.com', ['auth_basic' => $arg], HttpClientInterface::OPTIONS_DEFAULTS);
|
||||
$this->assertSame('Basic '.$result, $options['headers']['authorization'][0]);
|
||||
$this->assertSame('Authorization: Basic '.$result, $options['normalized_headers']['authorization'][0]);
|
||||
}
|
||||
|
||||
public function provideFingerprints()
|
||||
|
@ -44,9 +44,9 @@ class ScopingHttpClientTest extends TestCase
|
||||
$client = new ScopingHttpClient($mockClient, $options);
|
||||
|
||||
$response = $client->request('GET', $url);
|
||||
$reuestedOptions = $response->getRequestOptions();
|
||||
$requestedOptions = $response->getRequestOptions();
|
||||
|
||||
$this->assertEquals($reuestedOptions['case'], $options[$regexp]['case']);
|
||||
$this->assertSame($options[$regexp]['case'], $requestedOptions['case']);
|
||||
}
|
||||
|
||||
public function provideMatchingUrls()
|
||||
@ -64,8 +64,8 @@ class ScopingHttpClientTest extends TestCase
|
||||
public function testMatchingUrlsAndOptions()
|
||||
{
|
||||
$defaultOptions = [
|
||||
'.*/foo-bar' => ['headers' => ['x-app' => 'unit-test-foo-bar']],
|
||||
'.*' => ['headers' => ['content-type' => 'text/html']],
|
||||
'.*/foo-bar' => ['headers' => ['X-FooBar' => 'unit-test-foo-bar']],
|
||||
'.*' => ['headers' => ['Content-Type' => 'text/html']],
|
||||
];
|
||||
|
||||
$mockClient = new MockHttpClient();
|
||||
@ -73,20 +73,20 @@ class ScopingHttpClientTest extends TestCase
|
||||
|
||||
$response = $client->request('GET', 'http://example.com/foo-bar', ['json' => ['url' => 'http://example.com']]);
|
||||
$requestOptions = $response->getRequestOptions();
|
||||
$this->assertEquals($requestOptions['headers']['content-type'][0], 'application/json');
|
||||
$this->assertSame('Content-Type: application/json', $requestOptions['headers'][1]);
|
||||
$requestJson = json_decode($requestOptions['body'], true);
|
||||
$this->assertEquals($requestJson['url'], 'http://example.com');
|
||||
$this->assertEquals($requestOptions['headers']['x-app'][0], $defaultOptions['.*/foo-bar']['headers']['x-app']);
|
||||
$this->assertSame('http://example.com', $requestJson['url']);
|
||||
$this->assertSame('X-FooBar: '.$defaultOptions['.*/foo-bar']['headers']['X-FooBar'], $requestOptions['headers'][0]);
|
||||
|
||||
$response = $client->request('GET', 'http://example.com/bar-foo', ['headers' => ['x-app' => 'unit-test']]);
|
||||
$response = $client->request('GET', 'http://example.com/bar-foo', ['headers' => ['X-FooBar' => 'unit-test']]);
|
||||
$requestOptions = $response->getRequestOptions();
|
||||
$this->assertEquals($requestOptions['headers']['x-app'][0], 'unit-test');
|
||||
$this->assertEquals($requestOptions['headers']['content-type'][0], 'text/html');
|
||||
$this->assertSame('X-FooBar: unit-test', $requestOptions['headers'][0]);
|
||||
$this->assertSame('Content-Type: text/html', $requestOptions['headers'][1]);
|
||||
|
||||
$response = $client->request('GET', 'http://example.com/foobar-foo', ['headers' => ['x-app' => 'unit-test']]);
|
||||
$response = $client->request('GET', 'http://example.com/foobar-foo', ['headers' => ['X-FooBar' => 'unit-test']]);
|
||||
$requestOptions = $response->getRequestOptions();
|
||||
$this->assertEquals($requestOptions['headers']['x-app'][0], 'unit-test');
|
||||
$this->assertEquals($requestOptions['headers']['content-type'][0], 'text/html');
|
||||
$this->assertSame('X-FooBar: unit-test', $requestOptions['headers'][0]);
|
||||
$this->assertSame('Content-Type: text/html', $requestOptions['headers'][1]);
|
||||
}
|
||||
|
||||
public function testForBaseUri()
|
||||
|
@ -40,8 +40,8 @@ interface HttpClientInterface
|
||||
// value if they are not defined - typically "application/json"
|
||||
'user_data' => null, // mixed - any extra data to attach to the request (scalar, callable, object...) that
|
||||
// MUST be available via $response->getInfo('user_data') - not used internally
|
||||
'max_redirects' => 20, // int - the maximum number of redirects to follow; a value lower or equal to 0 means
|
||||
// redirects should not be followed; "Authorization" and "Cookie" headers MUST
|
||||
'max_redirects' => 20, // int - the maximum number of redirects to follow; a value lower than or equal to 0
|
||||
// means redirects should not be followed; "Authorization" and "Cookie" headers MUST
|
||||
// NOT follow except for the initial host name
|
||||
'http_version' => null, // string - defaults to the best supported version, typically 1.1 or 2.0
|
||||
'base_uri' => null, // string - the URI to resolve relative URLs, following rules in RFC 3986, section 2
|
||||
|
Reference in New Issue
Block a user