Navigation Menu

Skip to content

Commit

Permalink
[HttpClient] Preserve the case of headers when sending them
Browse files Browse the repository at this point in the history
  • Loading branch information
nicolas-grekas committed Jul 31, 2019
1 parent 7759fab commit 92d8add
Show file tree
Hide file tree
Showing 6 changed files with 81 additions and 78 deletions.
32 changes: 17 additions & 15 deletions CurlHttpClient.php
Expand Up @@ -108,12 +108,14 @@ public function request(string $method, string $url, array $options = []): Respo
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));
Expand Down Expand Up @@ -206,11 +208,11 @@ public function request(string $method, string $url, array $options = []): Respo
$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);
Expand All @@ -221,7 +223,7 @@ public function request(string $method, string $url, array $options = []): Respo

// 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.':';
}
}
Expand All @@ -237,9 +239,9 @@ public function request(string $method, string $url, array $options = []): Respo
};
}

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
}

Expand Down Expand Up @@ -387,12 +389,12 @@ private static function createRedirectResolver(array $options, string $host): \C
$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:');
});
}
Expand Down
68 changes: 34 additions & 34 deletions HttpClientTrait.php
Expand Up @@ -50,7 +50,10 @@ private static function prepareRequest(?string $method, ?string $url, array $opt
}
$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'])) {
Expand All @@ -61,19 +64,6 @@ private static function prepareRequest(?string $method, ?string $url, array $opt
$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)));
Expand Down Expand Up @@ -102,15 +92,14 @@ private static function prepareRequest(?string $method, ?string $url, array $opt

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
Expand All @@ -124,7 +113,6 @@ private static function prepareRequest(?string $method, ?string $url, array $opt
}

// 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'));

Expand All @@ -136,31 +124,38 @@ private static function prepareRequest(?string $method, ?string $url, array $opt
*/
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) {
Expand All @@ -169,7 +164,7 @@ private static function mergeDefaultOptions(array $options, array $defaultOption

// 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;
}

Expand All @@ -188,9 +183,9 @@ private static function mergeDefaultOptions(array $options, array $defaultOption
}

/**
* 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
{
Expand All @@ -204,10 +199,15 @@ private static function normalizeHeaders(array $headers): array
$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));
}
}
}

Expand Down
26 changes: 13 additions & 13 deletions NativeHttpClient.php
Expand Up @@ -73,13 +73,13 @@ public function request(string $method, string $url, array $options = []): Respo

$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']) {
Expand Down Expand Up @@ -160,12 +160,12 @@ public function request(string $method, string $url, array $options = []): Respo

[$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 = [
Expand Down Expand Up @@ -208,7 +208,7 @@ public function request(string $method, string $url, array $options = []): Respo

$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);
}
Expand Down Expand Up @@ -335,12 +335,12 @@ private static function createRedirectResolver(array $options, string $host, ?ar
$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:');
});
}
Expand Down Expand Up @@ -393,7 +393,7 @@ private static function createRedirectResolver(array $options, string $host, ?ar
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);
}

Expand Down
1 change: 1 addition & 0 deletions Response/NativeResponse.php
Expand Up @@ -241,6 +241,7 @@ private static function perform(NativeClientState $multi, array &$responses = nu
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
Expand Down
6 changes: 3 additions & 3 deletions Tests/HttpClientTraitTest.php
Expand Up @@ -172,8 +172,8 @@ public function provideRemoveDotSegments()
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']);
}

/**
Expand Down Expand Up @@ -226,7 +226,7 @@ public function providePrepareAuthBasic()
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()
Expand Down

0 comments on commit 92d8add

Please sign in to comment.