Repository navigation
feat!: pin webhook connections to the ip the url policy validated - #525
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
WebhookUrlPolicyresolved 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.RequestOptions::RESOLVE_TO('resolve_to'). It connects to a pre-resolved IP and keeps the URL's host for theHostheader, SNI and certificate checks. Validation requires an IP literal, rejects it alongsideproxy, and requires'allow_redirects' => false, because the pin only covers the URL's host:port. Drivers that cannot pin must throwInvalidRequestOptionException::unsupportedByDriver()and never ignore the option.resolve_toto Guzzle'scurloption withCURLOPT_RESOLVE(host:port:addr, IPv6 bracketed, default port 443/80) plusCURLOPT_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: nocurlextension (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-hatchcurloptions are kept.WebhookUrlPolicyInterface::validate()now returns the approved IP (the first address the host resolved to; every address is still checked).WebhookDispatcherpasses it asresolve_to. The "DNS rebinding" caution in the docs is replaced with an explanation of the pinning.Breaking
WebhookUrlPolicyInterface::validate(string $url)now returnsstring(the IP to connect to) instead ofvoid. Custom policy implementations must return the address the request should connect to. Callers that ignore the return value need no change.resolve_to. Withmarko/http-guzzlethat means the PHPcurlextension, and without it delivery throwsInvalidRequestOptionExceptionand nothing is sent unpinned. Escape hatch: bind a customWebhookDispatcheror HTTP driver.Deviation from the issue
CURLOPT_RESOLVEorforce_ip_resolveplus a custom resolver. This PR usesCURLOPT_RESOLVE, and addsCURLOPT_FRESH_CONNECTto defeat connection reuse.resolve_torequiresallow_redirects => false. It doesn't silently disable redirects, because a redirect to another host would escape the pin.Test plan
RequestOptionsTest:resolve_toaccepts IPv4/IPv6. It rejects hostnames, bracketed IPv6, empty strings, ints and arrays, a proxy alongside it, and redirects that are not disabled.GuzzleHttpClientTest: theCURLOPT_RESOLVEentry is correct for https/http/explicit port/IPv6, andFRESH_CONNECTis set. Nocurlkey is added when the option is absent, and escape-hatch curl options are merged. Every bypass case is rejected before sending.GuzzleHttpClientTestlive check: real cURL against aphp -Sserver on 127.0.0.1.http://pinned.marko.invalid:PORT/withresolve_to => 127.0.0.1succeeds and the server sees the originalHostheader. The same unresolvable name fails withConnectionExceptionwithout the pin.WebhookDispatcherTest: dispatch pins the address the policy validated. ARebindingHostResolver(public on lookup 1,169.254.169.254on 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 andphpstangreen locallyFixes #514
🤖 Generated with Claude Code
https://claude.ai/code/session_01KyjR4cJTfro54iFZ9CWkv1