Skip to content

Commit

Permalink
Merge pull request from GHSA-8274-h5jp-97vr
Browse files Browse the repository at this point in the history
Security/x forwarded header trust
  • Loading branch information
weierophinney committed Jun 28, 2022
2 parents d1bc565 + 4b5d1ad commit 25b11d4
Show file tree
Hide file tree
Showing 20 changed files with 1,522 additions and 190 deletions.
359 changes: 182 additions & 177 deletions composer.lock

Large diffs are not rendered by default.

28 changes: 22 additions & 6 deletions docs/book/v2/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,10 @@ $jsonResponse = new JsonResponse($data, 422, [

## ServerRequestFactory

This static class can be used to marshal a `ServerRequest` instance from the PHP environment. The
primary entry point is `Laminas\Diactoros\ServerRequestFactory::fromGlobals(array $server, array
$query, array $body, array $cookies, array $files)`. This method will create a new `ServerRequest`
instance with the data provided. Examples of usage are:
This static class can be used to marshal a `ServerRequest` instance from the PHP environment.
The primary entry point is `Laminas\Diactoros\ServerRequestFactory::fromGlobals(array $server, array $query, array $body, array $cookies, array $files, ?Laminas\Diactoros\ServerRequestFilter\FilterServerRequestInterface $requestFilter)`.
This method will create a new `ServerRequest` instance with the data provided.
Examples of usage are:

```php
// Returns new ServerRequest instance, using values from superglobals:
Expand All @@ -124,8 +124,22 @@ $request = ServerRequestFactory::fromGlobals(
$_COOKIE,
$_FILES
);

### Request Filters

Since version 2.11.1, this method takes the additional optional argument `$requestFilter`.
This should be a `null` value, or an instance of [`Laminas\Diactoros\ServerRequestFilter\FilterServerRequestInterface`](server-request-filters.md).
For version 2 releases, if a `null` is provided, internally the method will assign a [`Laminas\Diactoros\ServerRequestFilter\FilerUsingXForwardedHeaders`](server-request-filters.md#filterusingxforwardedheaders) instance configured as follows:

```php
$requestFilter = $requestFilter ?: FilterUsingXForwardedHeaders::trustReservedSubnets();
```

The request filter is called on the generated server request instance, and its result is returned from `fromGlobals()`.

**For version 3 releases, this method will switch to using a `Laminas\Diactoros\ServerRequestFilter\DoNotFilter` by default.**
If you are using this factory method directly, please be aware and update your code accordingly.

### ServerRequestFactory Helper Functions

In order to create the various artifacts required by a `ServerRequest` instance,
Expand All @@ -137,8 +151,10 @@ and even the `Cookie` header. These include:
(its main purpose is to aggregate the `Authorization` header in the SAPI params
when under Apache)
- `Laminas\Diactoros\marshalProtocolVersionFromSapi(array $server) : string`
- `Laminas\Diactoros\marshalMethodFromSapi(array $server) : string`
- `Laminas\Diactoros\marshalUriFromSapi(array $server, array $headers) : Uri`
- `Laminas\Diactoros\marshalMethodFromSapi(array $server) : string`.
- `Laminas\Diactoros\marshalUriFromSapi(array $server, array $headers) : Uri`.
Please note: **this function is deprecated as of version 2.11.1**, and no longer used in `ServerRequestFactory::fromGlobals()`.
Use `ServerRequestFactory::fromGlobals()` instead.
- `Laminas\Diactoros\marshalHeadersFromSapi(array $server) : array`
- `Laminas\Diactoros\parseCookieHeader(string $header) : array`
- `Laminas\Diactoros\createUploadedFile(array $spec) : UploadedFile` (creates the
Expand Down
20 changes: 20 additions & 0 deletions docs/book/v2/forward-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Preparing for Version 3

## ServerRequestFilterInterface defaults

Introduced in version 2.11.1, the `Laminas\Diactoros\ServerRequestFilter\FilterServerRequestInterface` is used by `ServerRequestFactory::fromGlobals()` to allow modifying the generated `ServerRequest` instance prior to returning it.
The primary use case is to allow modifying the generated URI based on the presence of headers such as `X-Forwarded-Host`.
When operating behind a reverse proxy, the `Host` header is often rewritten to the name of the node to which the request is being forwarded, and an `X-Forwarded-Host` header is generated with the original `Host` value to allow the server to determine the original host the request was intended for.
(We have always examined the `X-Forwarded-Proto` header; as of 2.11.1, we also examine the `X-Forwarded-Port` header.)

To accommodate this use case, we created `Laminas\Diactoros\ServerRequestFilter\FilterUsingXForwardedHeaders`.

Due to potential security issues, it is generally best to only accept these headers if you trust the reverse proxy that has initiated the request.
(This value is found in `$_SERVER['REMOTE_ADDR']`, which is present as `$request->getServerParams()['REMOTE_ADDR']` within PSR-7 implementations.)
`FilterUsingXForwardedHeaders` provides named constructors to allow you to trust these headers from any source (which has been the default behavior of Diactoros since the beginning), or to specify specific IP addresses or CIDR subnets to trust, along with which headers are trusted.
To prevent backwards compatibility breaks, we use this filter by default, marked to trust **only proxies on private subnets**.

Features will be added to the 3.11.0 version of [mezzio/mezzio](https://github.com/mezzio/mezzio) that will allow configuring the `Laminas\Diactoros\ServerRequestFilter\FilterServerRequestInterface` instance, and we recommend explicitly configuring this to utilize the `FilterUsingXForwardedHeaders` if you depend on this functionality.
If you **do not** need the functionality, we recommend specifying `Laminas\Diactoros\ServerRequestFilter\DoNotFilter` as the configured `FilterServerRequestInterface` in your application immediately.

We will update this documentation with a link to the related functionality in mezzio/mezzio when it is published.
112 changes: 112 additions & 0 deletions docs/book/v2/server-request-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Server Request Filters

INFO: **New Feature**
Available since version 2.11.1

Server request filters allow you to modify the initial state of a generated `ServerRequest` instance as returned from `Laminas\Diactoros\ServerRequestFactory::fromGlobals()`.
Common use cases include:

- Generating and injecting a request ID.
- Modifying the request URI based on headers provided (e.g., based on the `X-Forwarded-Host` or `X-Forwarded-Proto` headers).

## FilerServerRequestInterface

A request filter implements `Laminas\Diactoros\ServerRequestFilter\FilterServerRequestInterface`:

```php
namespace Laminas\Diactoros\ServerRequestFilter;

use Psr\Http\Message\ServerRequestInterface;

interface FilterServerRequestInterface
{
public function __invoke(ServerRequestInterface $request): ServerRequestInterface;
}
```

## Implementations

We provide the following implementations:

- `DoNotFilter`: returns the provided `$request` verbatim.
- `FilterUsingXForwardedHeaders`: if the originating request comes from a trusted proxy, examines the `X-Forwarded-*` headers, and returns the request instance with a URI instance that reflects those headers.

### DoNotFilter

This filter returns the `$request` argument back verbatim when invoked.

### FilterUsingXForwardedHeaders

Servers behind a reverse proxy need mechanisms to determine the original URL requested.
As such, reverse proxies have provided a number of mechanisms for delivering this information, with the use of `X-Forwarded-*` headers being the most prevalant.
These include:

- `X-Forwarded-Host`: the original `Host` header value.
- `X-Forwarded-Port`: the original port included in the `Host` header value.
- `X-Forwarded-Proto`: the original URI scheme used to make the request (e.g., "http" or "https").

`Laminas\Diactoros\ServerRequestFilter\FilterUsingXForwardedHeaders` provides named constructors for choosing whether to never trust proxies, always trust proxies, or choose wich proxies and/or headers to trust in order to modify the URI composed in the request instance to match the original request.
These named constructors are:

- `FilterUsingXForwardedHeadersFactory::trustProxies(string[] $proxyCIDRList, string[] $trustedHeaders = FilterUsingXForwardedHeaders::X_FORWARDED_HEADERS): void`: when this method is called, only requests originating from the trusted proxy/ies will be considered, as well as only the headers specified.
Proxies may be specified by IP address, or using [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing) for subnets; both IPv4 and IPv6 are accepted.
The special string "*" will be translated to two entries, `0.0.0.0/0` and `::/0`.
- `FilterUsingXForwardedHeaders::trustAny(): void`: when this method is called, the filter will trust requests from any origin, and use any of the above headers to modify the URI instance.
It is functionally equivalent to `FilterUsingXForwardedHeaders::trustProxies(['*'])`.
- `FilterUsingXForwardedHeaders::trustReservedSubnets(): void`: when this method is called, the filter will trust requests made from reserved, private subnets.
It is functionally equivalent to `FilterUsingXForwardedHeaders::trustProxies()` with the following elements in the `$proxyCIDRList`:
- 10.0.0.0/8
- 127.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- ::1/128 (IPv6 localhost)
- fc00::/7 (IPv6 private networks)
- fe80::/10 (IPv6 local-link addresses)

Internally, the filter checks the `REMOTE_ADDR` server parameter (as retrieved from `getServerParams()`) and compares it against each proxy listed; the first to match indicates trust.

#### Constants

The `FilterUsingXForwardedHeaders` defines the following constants for use in specifying various headers:

- `HEADER_HOST`: corresponds to `X-Forwarded-Host`.
- `HEADER_PORT`: corresponds to `X-Forwarded-Port`.
- `HEADER_PROTO`: corresponds to `X-Forwarded-Proto`.

#### Example usage

Trusting all `X-Forwarded-*` headers from any source:

```php
$filter = FilterUsingXForwardedHeaders::trustAny();
```

Trusting only the `X-Forwarded-Host` header from any source:

```php
$filter = FilterUsingXForwardedHeaders::trustProxies('0.0.0.0/0', [FilterUsingXForwardedHeaders::HEADER_HOST]);
```

Trusting the `X-Forwarded-Host` and `X-Forwarded-Proto` headers from a single Class C subnet:

```php
$filter = FilterUsingXForwardedHeaders::trustProxies(
'192.168.1.0/24',
[FilterUsingXForwardedHeaders::HEADER_HOST, FilterUsingXForwardedHeaders::HEADER_PROTO]
);
```

Trusting the `X-Forwarded-Host` header from either a Class A or a Class C subnet:

```php
$filter = FilterUsingXForwardedHeaders::trustProxies(
['10.1.1.0/16', '192.168.1.0/24'],
[FilterUsingXForwardedHeaders::HEADER_HOST, FilterUsingXForwardedHeaders::HEADER_PROTO]
);
```

Trusting any `X-Forwarded-*` header from any private subnet:

```php
$filter = FilterUsingXForwardedHeaders::trustReservedSubnets();
```
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ nav:
- Usage: v2/usage.md
- Reference:
- Factories: v2/factories.md
- "Server Request Filters": v2/server-request-filters.md
- "Custom Responses": v2/custom-responses.md
- Serialization: v2/serialization.md
- API: v2/api.md
- Migration:
- "Migration to Version 2": v2/migration.md
- "Preparing for Version 3": v2/forward-migration.md
- v1:
- Overview: v1/overview.md
- Installation: v1/install.md
Expand Down
34 changes: 33 additions & 1 deletion psalm-baseline.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="4.21.0@d8bec4c7aaee111a532daec32fb09de5687053d1">
<files psalm-version="4.23.0@f1fe6ff483bf325c803df9f510d09a03fd796f88">
<file src="src/CallbackStream.php">
<ImplementedReturnTypeMismatch occurrences="1">
<code>null|callable</code>
Expand Down Expand Up @@ -242,13 +242,45 @@
</ParamNameMismatch>
</file>
<file src="src/ServerRequestFactory.php">
<LessSpecificReturnStatement occurrences="1"/>
<MixedArgument occurrences="1">
<code>$headers['cookie']</code>
</MixedArgument>
<MixedAssignment occurrences="3">
<code>$iisUrlRewritten</code>
<code>$requestUri</code>
<code>$unencodedUrl</code>
</MixedAssignment>
<MixedInferredReturnType occurrences="1">
<code>array{string, int|null}</code>
</MixedInferredReturnType>
<MixedReturnStatement occurrences="1">
<code>$defaults</code>
</MixedReturnStatement>
<MoreSpecificReturnType occurrences="1">
<code>ServerRequest</code>
</MoreSpecificReturnType>
<RedundantConditionGivenDocblockType occurrences="1">
<code>is_callable(self::$apacheRequestHeaders)</code>
</RedundantConditionGivenDocblockType>
</file>
<file src="src/ServerRequestFilter/FilterUsingXForwardedHeaders.php">
<ImpureMethodCall occurrences="7">
<code>getHeaderLine</code>
<code>getServerParams</code>
<code>getUri</code>
<code>withHost</code>
<code>withPort</code>
<code>withScheme</code>
<code>withUri</code>
</ImpureMethodCall>
<LessSpecificReturnStatement occurrences="1">
<code>$proxyCIDRList</code>
</LessSpecificReturnStatement>
<MoreSpecificReturnType occurrences="1">
<code>list&lt;non-empty-string&gt;</code>
</MoreSpecificReturnType>
</file>
<file src="src/Stream.php">
<InvalidArgument occurrences="1"/>
<MixedInferredReturnType occurrences="1">
Expand Down
15 changes: 15 additions & 0 deletions psalm.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,26 @@
</projectFiles>

<issueHandlers>
<DeprecatedFunction>
<errorLevel type="suppress">
<referencedFunction name="laminas\diactoros\marshalurifromsapi"/>
</errorLevel>
</DeprecatedFunction>

<InternalClass>
<errorLevel type="suppress">
<referencedClass name="Laminas\Diactoros\ServerRequestFilter\IPRange"/>
</errorLevel>
</InternalClass>

<InternalMethod>
<errorLevel type="suppress">
<referencedMethod name="PHPUnit\Framework\MockObject\Builder\InvocationMocker::method"/>
<referencedMethod name="PHPUnit\Framework\MockObject\Builder\InvocationMocker::willReturn"/>
<referencedMethod name="PHPUnit\Framework\MockObject\Builder\InvocationMocker::with"/>
<referencedMethod name="Laminas\Diactoros\ServerRequestFilter\IPRange::matches"/>
<referencedMethod name="Laminas\Diactoros\ServerRequestFilter\IPRange::matchesIPv4"/>
<referencedMethod name="Laminas\Diactoros\ServerRequestFilter\IPRange::matchesIPv6"/>
</errorLevel>
</InternalMethod>

Expand Down
18 changes: 18 additions & 0 deletions src/ConfigProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@

class ConfigProvider
{
public const CONFIG_KEY = 'laminas-diactoros';
public const X_FORWARDED = 'x-forwarded-request-filter';
public const X_FORWARDED_TRUSTED_PROXIES = 'trusted-proxies';
public const X_FORWARDED_TRUSTED_HEADERS = 'trusted-headers';

/**
* Retrieve configuration for laminas-diactoros.
*
Expand All @@ -22,6 +27,7 @@ public function __invoke() : array
{
return [
'dependencies' => $this->getDependencies(),
self::CONFIG_KEY => $this->getComponentConfig(),
];
}

Expand All @@ -31,6 +37,7 @@ public function __invoke() : array
*/
public function getDependencies() : array
{
// @codingStandardsIgnoreStart
return [
'invokables' => [
RequestFactoryInterface::class => RequestFactory::class,
Expand All @@ -41,5 +48,16 @@ public function getDependencies() : array
UriFactoryInterface::class => UriFactory::class
],
];
// @codingStandardsIgnoreEnd
}

public function getComponentConfig(): array
{
return [
self::X_FORWARDED => [
self::X_FORWARDED_TRUSTED_PROXIES => '',
self::X_FORWARDED_TRUSTED_HEADERS => [],
],
];
}
}
24 changes: 24 additions & 0 deletions src/Exception/InvalidForwardedHeaderNameException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Laminas\Diactoros\Exception;

use Laminas\Diactoros\ServerRequestFilter\FilterUsingXForwardedHeaders;

class InvalidForwardedHeaderNameException extends RuntimeException implements ExceptionInterface
{
/** @param mixed $name */
public static function forHeader($name): self
{
if (! is_string($name)) {
$name = sprintf('(value of type %s)', is_object($name) ? get_class($name) : gettype($name));
}

return new self(sprintf(
'Invalid X-Forwarded-* header name "%s" provided to %s',
$name,
FilterUsingXForwardedHeaders::class
));
}
}
29 changes: 29 additions & 0 deletions src/Exception/InvalidProxyAddressException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Laminas\Diactoros\Exception;

class InvalidProxyAddressException extends RuntimeException implements ExceptionInterface
{
/** @param mixed $proxy */
public static function forInvalidProxyArgument($proxy): self
{
$type = is_object($proxy) ? get_class($proxy) : gettype($proxy);
return new self(sprintf(
'Invalid proxy of type "%s" provided;'
. ' must be a valid IPv4 or IPv6 address, optionally with a subnet mask provided'
. ' or an array of such values',
$type,
));
}

public static function forAddress(string $address): self
{
return new self(sprintf(
'Invalid proxy address "%s" provided;'
. ' must be a valid IPv4 or IPv6 address, optionally with a subnet mask provided',
$address,
));
}
}

0 comments on commit 25b11d4

Please sign in to comment.