Skip to content

feat!: pin webhook connections to the ip the url policy validated - #525

Merged
markshust merged 1 commit into
developfrom
feature/http-client-ip-pinning
Oct 6, 2026
Merged

markshust merged 1 commit into
developfrom
feature/http-client-ip-pinning

Conversation

@markshust

Copy link
Copy Markdown
Collaborator

Summary

  • Problem: WebhookUrlPolicy resolved the host and rejected internal ranges, then the HTTP client resolved the name again. A DNS answer that flipped from public to internal between the two lookups (rebinding) got past the check.
  • marko/http: new portable option RequestOptions::RESOLVE_TO ('resolve_to'). It connects to a pre-resolved IP and keeps the URL's host for the Host header, SNI and certificate checks. Validation requires an IP literal, rejects it alongside proxy, and requires 'allow_redirects' => false, because the pin only covers the URL's host:port. Drivers that cannot pin must throw InvalidRequestOptionException::unsupportedByDriver() and never ignore the option.
  • marko/http-guzzle: maps resolve_to to Guzzle's curl option with CURLOPT_RESOLVE (host:port:addr, IPv6 bracketed, default port 443/80) plus CURLOPT_FRESH_CONNECT, so a pooled connection opened to an earlier DNS answer is never reused. It throws before sending in any setup that would silently skip the pin: no curl extension (Guzzle would fall back to the stream handler), guzzle.stream, guzzle.proxy, guzzle.allow_redirects, guzzle.curl[CURLOPT_RESOLVE|CURLOPT_CONNECT_TO], a relative or non-http(s) URL, or an IP-literal host that differs from the pin. Other escape-hatch curl options are kept.
  • marko/webhook: WebhookUrlPolicyInterface::validate() now returns the approved IP (the first address the host resolved to; every address is still checked). WebhookDispatcher passes it as resolve_to. The "DNS rebinding" caution in the docs is replaced with an explanation of the pinning.
  • Docs updated for http, http-guzzle and webhook.

Breaking

  • WebhookUrlPolicyInterface::validate(string $url) now returns string (the IP to connect to) instead of void. Custom policy implementations must return the address the request should connect to. Callers that ignore the return value need no change.
  • Webhook delivery now needs an HTTP driver that supports resolve_to. With marko/http-guzzle that means the PHP curl extension, and without it delivery throws InvalidRequestOptionException and nothing is sent unpinned. Escape hatch: bind a custom WebhookDispatcher or HTTP driver.

Deviation from the issue

  • The issue offered CURLOPT_RESOLVE or force_ip_resolve plus a custom resolver. This PR uses CURLOPT_RESOLVE, and adds CURLOPT_FRESH_CONNECT to defeat connection reuse.
  • resolve_to requires allow_redirects => false. It doesn't silently disable redirects, because a redirect to another host would escape the pin.

Test plan

  • RequestOptionsTest: resolve_to accepts IPv4/IPv6. It rejects hostnames, bracketed IPv6, empty strings, ints and arrays, a proxy alongside it, and redirects that are not disabled.
  • GuzzleHttpClientTest: the CURLOPT_RESOLVE entry is correct for https/http/explicit port/IPv6, and FRESH_CONNECT is set. No curl key is added when the option is absent, and escape-hatch curl options are merged. Every bypass case is rejected before sending.
  • GuzzleHttpClientTest live check: real cURL against a php -S server on 127.0.0.1. http://pinned.marko.invalid:PORT/ with resolve_to => 127.0.0.1 succeeds and the server sees the original Host header. The same unresolvable name fails with ConnectionException without the pin.
  • WebhookDispatcherTest: dispatch pins the address the policy validated. A RebindingHostResolver (public on lookup 1, 169.254.169.254 on lookup 2) with an HTTP client that resolves on its own when unpinned: the dispatcher connects only to the public IP and DNS is queried once. A control test shows the same client would reach the metadata IP if it re-resolved.
  • WebhookUrlPolicyTest: validate() returns the first resolved address and returns IP-literal hosts as themselves.
  • composer test, cs:check, php-cs-fixer dry-run and phpstan green locally

Fixes #514

🤖 Generated with Claude Code

https://claude.ai/code/session_01KyjR4cJTfro54iFZ9CWkv1

WebhookUrlPolicy resolved the host and rejected internal ranges, but the
HTTP client then resolved the name again, so a DNS answer that flipped to
an internal address between the two lookups (rebinding) slipped through.

- marko/http: new RequestOptions::RESOLVE_TO ('resolve_to') option that
  connects to a pre-resolved IP while keeping the Host header and TLS
  SNI. It must be an IP literal, cannot be combined with a proxy, and
  requires 'allow_redirects' => false. Drivers that cannot pin must throw
  InvalidRequestOptionException::unsupportedByDriver() instead of
  ignoring it.
- marko/http-guzzle: maps resolve_to to cURL CURLOPT_RESOLVE plus
  CURLOPT_FRESH_CONNECT; rejects setups that would bypass the pin
  (no curl extension, guzzle stream/proxy/redirect/resolve overrides,
  relative URLs, mismatched IP-literal hosts).
- marko/webhook: WebhookUrlPolicyInterface::validate() now returns the
  approved IP address and WebhookDispatcher pins the request to it.

Fixes #514

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KyjR4cJTfro54iFZ9CWkv1
@github-actions github-actions Bot added enhancement New feature or request breaking Introduces a breaking change labels Oct 6, 2026
@markshust
markshust merged commit d7012a9 into develop Oct 6, 2026
5 checks passed
@markshust
markshust deleted the feature/http-client-ip-pinning branch October 6, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Introduces a breaking change enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP client option to pin a resolved IP so webhook SSRF checks aren't defeated by DNS rebinding

1 participant