8000 [HttpClient] Preserve the case of headers when sending them by nicolas-grekas · Pull Request #32823 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[HttpClient] Preserve the case of headers when sending them #32823

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 31, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
[HttpClient] Preserve the case of headers when sending them
  • Loading branch information
nicolas-grekas committed Jul 31, 2019
commit 9ac85d5d8b81560b5af522899453242340b2e673
32 changes: 17 additions & 15 deletions src/Symfony/Component/HttpClient/CurlHttpClient.php
8000
Original file line number Diff line number Diff line change
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 src/Symfony/Component/HttpClient/HttpClientTrait.php
Original file line number Diff line number Diff line change
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 src/Symfony/Component/HttpClient/NativeHttpClient.php
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading
0