Repository navigation
Conversation
Match reserved networks with inet_pton and a mask so PHP 7.4 through 8.5 agree, without FILTER_FLAG_GLOBAL_RANGE. Also canonicalize the inet_aton spellings libcurl connects to without consulting DNS.
Block non-public addresses by default. One private address rejects the whole lookup, including when another returned address is allowlisted. Internationalized names need ext-intl; otherwise they are rejected.
A relative reference resolved against a base with an explicit port kept the host and dropped the port, so a later manual redirect would leave it.
Pin the addresses that were validated, using the punycode host libcurl looks up. Follow redirects in the dispatcher so every hop is checked, and read every curl_multi_info_read message.
CurlClient still validates inside the dispatcher, so this path does not resolve twice. Cached page tests use a fake resolver that returns a public address, otherwise dead hosts fail closed as NXDOMAIN.
A page-declared oEmbed endpoint, adapter API, or meta-refresh target that the policy rejects is ignored. The URL passed to get() and getMulti() still throws, and getMulti rejects the batch before any request is sent.
Describe the default block, the intranet opt-out, custom PSR-18 clients, and environment proxies. Record the change under Unreleased for 4.5.0.
ssl_verify_host now sends 2 for any truthy value, so true and 1 still check the hostname. A negative max_redirs stays unlimited, capped at 50. timeout is the budget for the whole redirect chain. Curl handle annotations keep the PHP 7.4 resource type, with the comments and phpstan-ignore notes that make that union analyse cleanly.
Drop the version from the changelog entry and point php-embed#573 at the same repository as the other links. Describe Teredo as a whole prefix, setUrlPolicy() only forwarding to a real CurlClient, and synchronous redirect checks inside getMulti. Record the max_redirs cap and the redirect-limit message.
Only a falsy ssl_verify_peer disables CURLOPT_SSL_VERIFYPEER, matching the value curl used to receive. The hostname assertion accepts both "host name" and "hostname". Restore new static, the multi-loop comments, and the resource|CurlHandle locals.
Serve the three pages from the cache and resolve them to a public address, so a resolver that answers 198.18.0.1 does not reject the test.
ML\JsonLD downloads that context while expanding JSON-LD, so Tumblr's publishedTime depended on the network. Serve a minimal vocabulary instead and record the date the page actually publishes.
|
Hi @markfullmer, can you try this? |
|
Testing and reviewing now, @Vitorinox .... |
|
This approach is comprehensive, intervening in the correct location in the code to preemptively catch any look-ups. In addition to feeling that the test coverage added here is comprehensive, I also ran this change through an integration I have of This approach has my full approval! |
|
Just a suggestion, too: I think this should be included in a semver patch-level release (i.e., |
Summary
Fixes #573.
This builds on ideas from #578 by @markfullmer (pinned DNS, HTTP(S)-only protocols, and redirects followed by the library rather than by libcurl). Thank you for that work. The check lives at request time instead of in URI resolution, which is what made relative URLs fail in that approach.
Validation runs on the absolute URI when a request is about to be sent: in
CurlDispatcher/CurlClient, and inCrawlerbefore a custom PSR-18 client.isHttp()andExtractor::resolveUri()are unchanged, except thatresolveUri()now keeps the base URL’s port.A 4.5.0 bump would match the size of the behavior change. The version is yours to pick.
Threat model
An attacker who can choose a URL Embed fetches, or who can place a URL in a page Embed will fetch next, can otherwise make the server request loopback, RFC 1918, link-local, CGNAT, and other non-public addresses. The vectors covered here are:
get()/getMulti()Location(port preserved), and protocol-relativeLocationfile://andgopher://(and any scheme other thanhttp/https), including as a redirect targetCURLOPT_RESOLVE2130706433,127.1, octal, hex), whichCURLOPT_RESOLVEdoes not override2001::/32prefix rather than reading an address out of itAuthorizationandCookieare removed when a redirect changes hostA remote JSON-LD
@contextis not covered.ML\JsonLDstill loads it withfile_get_contentsand follows redirects. That is a separate change; see Follow-ups.Design
UrlPolicyis immutable and on by default. It allows onlyhttpandhttps, rejects userinfo, backslashes, empty hosts, and control characters, and requires every resolved IPv4 address to be public. One non-public address rejects the lookup. IP literals, including inet_aton spellings, are classified without DNS. Internationalized names are converted withidn_to_ascii(); withoutext-intlthey are rejected. Failure messages stay generic so a resolved internal address is not leaked.BlockedRequestExceptionextendsInvalidArgumentExceptionand implements PSR-18RequestExceptionInterface. DNS failure remainsNetworkExceptioncode 6.IpClassifiermatches explicit CIDR tables withinet_pton. It does not useFILTER_FLAG_GLOBAL_RANGE, which does not exist before PHP 8.2 and is a fatal error on PHP 8.0 and 8.1.CurlDispatcherturnsCURLOPT_FOLLOWLOCATIONoff and follows redirects itself, so every hop is validated.follow_locationset to false still returns the 3xx response.max_redirsis still the number of redirects to follow (10 by default). A negative value, which curl treated as unlimited, follows at most 50, so a redirect loop cannot run without a bound. Reaching the limit is still curl error 47. The message is now curl's genericNumber of redirects hit maximum amount, notMaximum (N) redirects followed.timeoutis the total time for the request and every redirect; each hop receives only the time still left.connect_timeoutstays per connection.ssl_verify_hostof0disables hostname checks. Any other value, includingtrue,1, and'1', is sent as2. Passing1straight through used to make PHP warn and then verify; mapping it to2keeps the check and drops the notice.ssl_verify_peeris disabled only for a falsy value (0,false,'0',''). Any other value, including1,2,'true', and'yes', keeps certificate verification on.The handle is reused, so cookies set on a redirect are still sent on the next hop.
CURLOPT_PROTOCOLSandCURLOPT_REDIR_PROTOCOLSare limited to HTTP and HTTPS (*_STRwhen the PHP build has them). The resolve entry uses the punycode host, because that is the name libcurl looks up. On PHP 8.4+,CURLOPT_PREREQFUNCTIONaborts before the bytes are sent if the connected address is not one that was validated. Every version also checksCURLINFO_PRIMARY_IPand refuses the response when it does not match.curl_multivalidates the whole batch before any transfer, andcurl_multi_info_readis read in a loop. A redirect found after the batch has started is validated on that same thread, including DNS, so the other transfers wait until the check returns.Content-Locationremains the final URL.A blocked oEmbed endpoint, adapter API, or meta-refresh target is skipped. Only the URL passed to
get()/getMulti()throws. One blocked URL ingetMulti()fails the batch before any request is sent.Why this is not in
resolveUri()Relative references (
/images/pic.jpg,//cdn.example/favicon.ico, a relative oEmbed href) are resolved long before anything is fetched, and many of them are never fetched. Checking them there rejects valid relative URLs and runs DNS for URLs the library only returns. Doing it on the absolute URI at request time leaves those results the same and checks the request that would actually leave the process.Configuration
Private and loopback URLs throw
Embed\Http\BlockedRequestException. Intranet installs can opt back in:Crawler::setUrlPolicy()copies the policy onto the client only when that client is exactlyEmbed\Http\CurlClient. A decorator aroundCurlClientis not updated. Set the policy on the inner client as well. Otherwise the wrapper is only pre-checked, and the inner client keeps the policy it already had.withAllowedHosts('wiki.corp', '*.corp')allows named hosts (and an explicit address) without opening every private network.*.corpmatcheswiki.corpanda.b.corp, not the apexcorp. Allowing one resolved address does not allow another address returned for the same name.withAllowedPorts([80, 443])is optional and off by default.If
http_proxy,https_proxy, orall_proxyis set, curl connects to the proxy and the proxy resolves the origin. Embed still validates each URL, and still checks redirects, but it cannot pin DNS or see the address the proxy used.Backward compatibility
Public signatures of
Embed,Crawler,CurlClient,isHttp(), andresolveUri()are unchanged. The new API is additive:UrlPolicy,BlockedRequestException,Crawler::setUrlPolicy()/getUrlPolicy(), and the same onCurlClient.The behavior change is intentional: non-public targets are rejected unless the caller opts out.
follow_location,ignored_errors, andCURL_IPRESOLVE_V4are kept.resolveUri()now preserves the base port (/xagainsthttp://h:8080/aishttp://h:8080/x).Differences from the previous curl follower:
max_redirs < 0is capped at 50 instead of being truly unlimited.Number of redirects hit maximum amount.timeoutstill bounds the whole chain, not each hop.ssl_verify_hostvalues other than0enable hostname verification, includingtrueand1.ssl_verify_peeris disabled only for falsy values (0,false,'0','').1,2,'true', and'yes'keep certificate verification on.Custom PSR-18 clients
Crawlervalidates beforesendRequest/sendRequestsand does not validate again inside that client. It also does not double-resolve when the client isCurlClient.What a custom client still has to handle:
on_redirectexample. That hook still cannot see the connected address.Prefer the built-in client for untrusted pages. Extracted URLs (
image,favicon, and the rest) are not downloaded; callgetUrlPolicy()->validate()before fetching one yourself.Tests
Offline and deterministic. The classifier and the policy assert both the exception type and
getReason(). Integration tests run against a localphp -Sfor redirects, relative and protocol-relativeLocation, forbidden schemes, the redirect limit, a negativemax_redirs, a timeout that spans hops, hostname verification (0/false/'0'versustrue/1/'1'/2, accepting both curl wordings of the hostname mismatch), certificate verification for falsy and non-falsyssl_verify_peer, cookies across hops,Authorizationstripped on a host change, parallel requests, a batch rejected before any request, and DNS rebinding (the resolver’s second answer is never used). A stub PSR-18 client checks that relativeog:image, a protocol-relative favicon,hreflang, and a relative oEmbed endpoint match the previous results.PagesTest,DocumentTest, andMultipleRequestsTestuse a fake resolver that returns a public address, so they do not depend on live DNS.Tested locally on PHP 7.4, 8.3, 8.4 and 8.5; 8.0–8.2 rely on CI.
Follow-ups
ML\JsonLDfrom fetching remote@contextdocuments (including a page-supplied URL that redirects internally). That change also explains the existing TumblrpublishedTimefixture, which isnullbecause the schema.org fetch failed when the fixture was recorded. This PR does not changeLinkedDataor that fixture.