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:
Nicolas Grekas 2019-07-31 17:05:45 +02:00
commit 9ac35529b6
7 changed files with 83 additions and 80 deletions

View File

@ -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:');
});
}

View File

@ -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));
}
}
}

View File

@ -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);
}

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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