Skip to content
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
15 changes: 14 additions & 1 deletion packages/docs-markdown/docs/packages/http-guzzle.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,23 @@ Portable options map to Guzzle as follows:
| `allow_redirects` `true`/`false` | Guzzle `allow_redirects` |
| `allow_redirects` `int` | Guzzle `allow_redirects` with `['max' => $int]` |
| `http_errors` | Guzzle `http_errors` (default `true`) |
| `resolve_to` | Guzzle `curl` options `CURLOPT_RESOLVE` (`host:port:address`) and `CURLOPT_FRESH_CONNECT` |

### Pinning to an IP

`resolve_to` (see [Pinning the Connection to an IP](/docs/packages/http/#pinning-the-connection-to-an-ip)) is implemented with cURL's `CURLOPT_RESOLVE`, which maps the URL's host and port (`443`/`80` by default) to the given address, so the `Host` header, SNI and certificate checks keep the hostname. `CURLOPT_FRESH_CONNECT` makes cURL open a new connection rather than reuse a pooled one opened to an earlier DNS answer.

Only Guzzle's cURL handlers can pin a connection, so the request throws `InvalidRequestOptionException` before anything is sent when:

- the PHP `curl` extension is not loaded (Guzzle would fall back to its stream handler),
- the `guzzle` escape hatch sets `stream`, `proxy`, a non-`false` `allow_redirects`, or `curl` options `CURLOPT_RESOLVE` / `CURLOPT_CONNECT_TO`,
- the URL is not an absolute `http`/`https` URL, or its host is an IP literal other than the pinned address.

Other `curl` options in the escape hatch are kept alongside the pin. Proxies configured through `HTTP_PROXY`/`HTTPS_PROXY` environment variables are applied by Guzzle and cURL themselves; a proxied request is resolved by the proxy, so don't route pinned requests through one.

### The `guzzle` Escape Hatch

For a Guzzle feature outside the portable set (`cert`, `ssl_key`, `sink`, `on_stats`, `decode_content`, `debug`, ...), pass a `guzzle` array. It is merged verbatim over the options above, so it wins on conflict:
For a Guzzle feature outside the portable set (`cert`, `ssl_key`, `sink`, `on_stats`, `decode_content`, `debug`, ...), pass a `guzzle` array. It is merged verbatim over the options above, so it wins on conflict (except for the pinning options above, which are rejected rather than overridden):

```php
$response = $this->httpClient->get('https://internal.example.com/report.csv', [
Expand Down
29 changes: 27 additions & 2 deletions packages/docs-markdown/docs/packages/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ All methods accept an `$options` array. The keys are a portable set defined as c
| `allow_redirects` | `RequestOptions::ALLOW_REDIRECTS` | `bool`, or the maximum number of redirects as an `int` |
| `proxy` | `RequestOptions::PROXY` | Proxy URL |
| `http_errors` | `RequestOptions::HTTP_ERRORS` | `bool`, default `true` --- throw `HttpException` on 4xx/5xx |
| `resolve_to` | `RequestOptions::RESOLVE_TO` | IPv4 or IPv6 address (no brackets) to connect to instead of resolving the host --- see [Pinning the Connection to an IP](#pinning-the-connection-to-an-ip) |

```php
use Marko\Http\RequestOptions;
Expand All @@ -107,10 +108,34 @@ Options are validated before the request is sent. Each of these throws `InvalidR

- An unknown key, such as the typo `form_param` --- the message names the key and the suggestion lists every supported key.
- More than one body option (`body`, `json`, `form_params`, `multipart`) in the same request.
- A malformed `auth`, or a `http_errors`, `verify` or `allow_redirects` value of the wrong type.
- A malformed `auth`, or a `http_errors`, `verify`, `allow_redirects` or `resolve_to` value of the wrong type.
- `resolve_to` combined with `proxy`, or without `'allow_redirects' => false`.

Drivers may accept an extra, clearly non-portable key of their own (for example `guzzle` in [`marko/http-guzzle`](/docs/packages/http-guzzle/)). Any other key fails loudly instead of being ignored.

### Pinning the Connection to an IP

When you have already resolved and vetted a host --- for example to reject internal addresses before calling a user-supplied URL --- pass the address you checked as `resolve_to`. The client connects to that address instead of resolving the host again, while the `Host` header, TLS SNI and certificate verification still use the hostname from the URL:

```php
use Marko\Http\RequestOptions;

$response = $this->httpClient->post('https://hooks.example.com/in', [
RequestOptions::JSON => $event,
RequestOptions::RESOLVE_TO => '93.184.215.14',
RequestOptions::ALLOW_REDIRECTS => false,
]);
```

Without the pin, a hostname whose DNS answer changes between your check and the connection (DNS rebinding) can steer the request to an address you never approved. [`marko/webhook`](/docs/packages/webhook/#outgoing-url-policy) uses this option for every outgoing webhook.

The pin covers only the URL's host and port, so:

- `resolve_to` requires `'allow_redirects' => false` --- a redirect to another host would be resolved normally. Check and pin a redirect target yourself before following it.
- `resolve_to` cannot be combined with `proxy`, because a proxy resolves the destination itself.

A driver that cannot pin the connection throws `InvalidRequestOptionException` instead of ignoring the option, so the request is never silently sent unpinned.

### Error Responses

With the default `'http_errors' => true`, a 4xx or 5xx response throws `HttpException`, and the response is available from `getResponse()`:
Expand Down Expand Up @@ -217,6 +242,6 @@ Drivers call `validate()` before sending, passing any driver-specific keys they

| Exception | Description |
|-----------|-------------|
| `InvalidRequestOptionException` | Thrown before sending when an option key is unknown, body options conflict, or an option value has the wrong shape (extends `MarkoException`) |
| `InvalidRequestOptionException` | Thrown before sending when an option key is unknown, options conflict, an option value has the wrong shape, or the driver cannot honour an option such as `resolve_to` (extends `MarkoException`) |
| `HttpException` | Base exception for HTTP errors --- provides `getResponse()` to access the underlying `HttpResponse` if available |
| `ConnectionException` | Thrown when the HTTP connection itself fails (extends `HttpException`) |
12 changes: 8 additions & 4 deletions packages/docs-markdown/docs/packages/webhook.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,10 @@ public function register(string $url): void

Hostnames are resolved through `HostResolverInterface` (bound to `DnsHostResolver`, which uses the system resolver). Bind your own resolver to use a different DNS source, or a fixed map in tests. To allow destinations the default policy rejects, for example a receiver on your private network, bind your own `WebhookUrlPolicyInterface` implementation.

:::caution[DNS rebinding]
`marko/http` has no option to pin a request to an already-resolved IP, so the HTTP client resolves the host again when it connects. The policy runs immediately before each request to keep that window small, but a hostname whose DNS answer changes between the check and the connection can still reach an internal address. If your receivers are untrusted and your network exposes sensitive internal services, also block egress to internal ranges at the network level (firewall or egress proxy).
`validate()` returns the address the policy approved (the first one the host resolved to), and `WebhookDispatcher` pins the connection to it with the `marko/http` [`resolve_to`](/docs/packages/http/#pinning-the-connection-to-an-ip) option. The HTTP client never resolves the host a second time, so a hostname whose DNS answer flips to an internal address after the check (DNS rebinding) cannot redirect the delivery. The `Host` header and TLS certificate check still use the hostname. A custom `WebhookUrlPolicyInterface` implementation must return the IP address the request should connect to.

:::note[HTTP driver support]
Pinning needs an HTTP client driver that supports `resolve_to`, such as [`marko/http-guzzle`](/docs/packages/http-guzzle/#pinning-to-an-ip) with the PHP `curl` extension. A driver that cannot pin throws `InvalidRequestOptionException`, so a webhook is never sent unpinned. Deliveries are pinned to a direct connection; if your servers must reach the internet through an egress proxy, the proxy resolves the host itself, so enforce the internal-range block on the proxy too.
:::

### Sending Asynchronously with Retry
Expand Down Expand Up @@ -385,7 +387,8 @@ public function __construct(
);

// Returns a WebhookResponse for every HTTP response (4xx/5xx and 3xx included; redirects are not followed).
// Validates the URL with the URL policy first and sends webhook.timeout as the request timeout.
// Validates the URL with the URL policy first, pins the connection to the address it approved (resolve_to),
// and sends webhook.timeout as the request timeout.
// Sends X-Webhook-Id, X-Webhook-Timestamp and X-Webhook-Signature headers.
// @throws UnsafeWebhookUrlException when the URL policy rejects the URL (nothing is sent)
// @throws InvalidWebhookPayloadException when the data cannot be encoded as JSON (nothing is sent)
Expand All @@ -405,8 +408,9 @@ public function __construct(
HostResolverInterface $resolver,
);

// Returns the approved IP address to pin the connection to (the first address the host resolved to).
// @throws UnsafeWebhookUrlException
public function validate(string $url): void;
public function validate(string $url): string;
```

### DnsHostResolver
Expand Down
113 changes: 112 additions & 1 deletion packages/http-guzzle/src/GuzzleHttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public function request(
string $url,
array $options = [],
): HttpResponse {
$guzzleOptions = $this->buildOptions($options);
$guzzleOptions = $this->buildOptions($url, $options);

try {
$response = $this->client()->request($method, $url, $guzzleOptions);
Expand Down Expand Up @@ -150,6 +150,7 @@ private function client(): GuzzleClientInterface
* @return array<string, mixed>
*/
private function buildOptions(
string $url,
array $options,
): array {
RequestOptions::validate($options, [self::GUZZLE_OPTIONS]);
Expand All @@ -163,6 +164,17 @@ private function buildOptions(
unset($options[self::GUZZLE_OPTIONS]);

$guzzleOptions = $options;
unset($guzzleOptions[RequestOptions::RESOLVE_TO]);

if (isset($options[RequestOptions::RESOLVE_TO])) {
$guzzleOptions['curl'] = $this->pinnedCurlOptions(
$url,
(string) $options[RequestOptions::RESOLVE_TO],
$escapeHatch,
);
unset($escapeHatch['curl']);
}

$guzzleOptions[RequestOptions::HTTP_ERRORS] = RequestOptions::throwsOnHttpError($options);
$guzzleOptions[RequestOptions::TIMEOUT] ??= $this->timeoutFromConfig('timeout');
$guzzleOptions[RequestOptions::CONNECT_TIMEOUT] ??= $this->timeoutFromConfig('connect_timeout');
Expand All @@ -183,6 +195,105 @@ private function buildOptions(
return array_replace($guzzleOptions, $escapeHatch);
}

/**
* The cURL options that pin the connection to $address: CURLOPT_RESOLVE maps the URL's
* host and port to the address, so the Host header, TLS SNI and certificate check still
* use the hostname. CURLOPT_FRESH_CONNECT stops cURL from reusing a pooled connection
* that was opened to whatever address the host resolved to earlier.
*
* Only Guzzle's cURL handlers honour these options; its stream handler would silently
* ignore them, so anything that routes the request there is rejected.
*
* @param array<mixed> $escapeHatch
*
* @return array<int, mixed>
*
* @throws InvalidRequestOptionException
*/
private function pinnedCurlOptions(
string $url,
string $address,
array $escapeHatch,
): array {
$option = RequestOptions::RESOLVE_TO;

if (!extension_loaded('curl')) {
throw InvalidRequestOptionException::unsupportedByDriver(
$option,
self::class,
'Pinning the connection needs the PHP curl extension; without it Guzzle falls back to its'
. ' stream handler, which cannot pin a connection to an IP address.',
);
}

if (!empty($escapeHatch['stream'])) {
throw InvalidRequestOptionException::unsupportedByDriver(
$option,
self::class . " with the 'stream' Guzzle option",
'Guzzle sends streamed requests through its stream handler, which cannot pin a connection'
. ' to an IP address.',
);
}

if (array_key_exists('proxy', $escapeHatch)) {
throw InvalidRequestOptionException::conflictingOptions(
$option,
self::GUZZLE_OPTIONS . '.proxy',
'A proxy resolves the destination host itself, so the connection cannot be pinned to an IP.',
);
}

if (($escapeHatch[RequestOptions::ALLOW_REDIRECTS] ?? false) !== false) {
throw InvalidRequestOptionException::pinnedRequestFollowsRedirects();
}

$curl = $escapeHatch['curl'] ?? [];

if (!is_array($curl)) {
throw InvalidRequestOptionException::invalidType(self::GUZZLE_OPTIONS . '.curl', 'array', $curl);
}

foreach ([CURLOPT_RESOLVE => 'CURLOPT_RESOLVE', CURLOPT_CONNECT_TO => 'CURLOPT_CONNECT_TO'] as $curlOption => $name) {
if (array_key_exists($curlOption, $curl)) {
throw InvalidRequestOptionException::conflictingOptions(
$option,
self::GUZZLE_OPTIONS . ".curl[$name]",
"$name decides which address cURL connects to, which is what '$option' sets.",
);
}
}

$parts = parse_url($url);
$scheme = is_array($parts) ? strtolower($parts['scheme'] ?? '') : '';
$host = is_array($parts) ? ($parts['host'] ?? '') : '';

if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
throw InvalidRequestOptionException::unpinnableUrl();
}

$pinned = [CURLOPT_FRESH_CONNECT => true];
$literal = trim($host, '[]');

if (filter_var($literal, FILTER_VALIDATE_IP) !== false) {
// cURL never resolves an IP literal, so the URL itself must already name the pinned address.
if (inet_pton($literal) !== inet_pton($address)) {
throw InvalidRequestOptionException::conflictingOptions(
$option,
'the URL host',
"The URL names the IP address $literal, which cURL connects to directly instead of $address.",
);
}

return array_replace($curl, $pinned);
}

$port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
$target = str_contains($address, ':') ? "[$address]" : $address;
$pinned[CURLOPT_RESOLVE] = ["$host:$port:$target"];

return array_replace($curl, $pinned);
}

/**
* @throws ConfigException
*/
Expand Down
Loading
Loading