Skip to content

Make "require-public-ip" pin the connection to the checked IP - #361

Open
ShaiMagal wants to merge 3 commits into
rosell-dk:masterfrom
ShaiMagal:fix-wpc-public-ip-pinning
Open

ShaiMagal wants to merge 3 commits into
rosell-dk:masterfrom
ShaiMagal:fix-wpc-public-ip-pinning

Conversation

@ShaiMagal

@ShaiMagal ShaiMagal commented Aug 30, 2026 •

Copy link
Copy Markdown

What this changes

Wpc::checkOperationality() resolves the api url host with dns_get_record() and refuses the conversion when it points at a private address. The request that follows then hands the original host name to curl, which resolves it a second time. The two lookups are independent, so the address that was checked is not necessarily the address that is connected to.

If the second answer differs from the first, the multipart POST - the source image, the conversion options and, depending on the setup, the api key - can still reach an address the check was meant to exclude.

The option matters exactly where the api url is not fully trusted: the docs recommend it for integrations that let a user choose wpc-api-url, and it is off by default.

The fix

checkIfUrlIsPublicIp() becomes getPublicIpsOfUrl() and returns the addresses it validated instead of a bool. doActualConvert() passes them to curl through CURLOPT_RESOLVE, so the request connects to the addresses that were checked.

The url itself is passed on unchanged, so the Host header and the TLS SNI are unaffected. When the url already contains an IP there is no lookup to pin and nothing is added. All validated addresses are pinned, so an A + AAAA host keeps its fallback.

CURLOPT_PROTOCOLS and CURLOPT_REDIR_PROTOCOLS are already handled in CurlTrait::initCurl() and were left alone.

Tests

Two tests in tests/Convert/Converters/WPCTest.php cover the new behaviour, with a small WpcExposer in the style of the existing exposers. phpcs (PSR2) and phpstan at level 4 are clean.

Sponsor company: OpenServis.cz

Fixes #362

The option looked the api url host up and rejected private IPs, but the
request that followed handed the host name to curl, which looked it up
again. When the answer changed in between, the image, the options and the
api key could still end up at a private address.

The IPs from the check are now kept and passed on to curl in
CURLOPT_RESOLVE, so the request connects to the addresses that were
checked. The url itself is passed on unchanged, so the Host header and
the TLS SNI are unaffected.
@ShaiMagal
ShaiMagal force-pushed the fix-wpc-public-ip-pinning branch from 22c14df to e3188fc Compare September 3, 2026 22:03
CURLOPT_RESOLVE was added in curl 7.21.3, and ext/curl only registers the
constant when it is built against that version or newer. The option is now
checked in checkOperationality(), so the converter reports itself as not
operational instead of raising an Error mid-convert on PHP 8, or silently
skipping the pin on older PHP.

Also correct the documentation: it claimed the first IP was pinned on every
libcurl below 7.59.0, which is not true below 7.21.3, where pinning is not
available at all.
A proxy resolves the host itself and connects on our behalf, so with a proxy in
between, the request never reaches one of the IPs that ::checkOperationality()
checked, and CURLOPT_RESOLVE has no effect at all. libcurl takes a proxy from
the environment unless it is told not to, so on any setup with http_proxy (or
https_proxy / all_proxy) set, the option silently stopped delivering what it
promises.

Setting CURLOPT_PROXY to an empty string disables proxy use for the handle, also
when the environment holds one. It is only done when the option is enabled, so
nothing changes for anyone who does not use it.

Also noted in ::createResolveOption() why the IPs are not written within
[brackets]: libcurl only accepts those from 7.57.0, and an entry it cannot parse
is skipped, which would leave the request unpinned on the older versions that
this feature supports.
@ShaiMagal

Copy link
Copy Markdown
Author

Added one more commit: Bypass proxies when "require-public-ip" is enabled.

While re-checking this branch I found that the pin can be discarded without any error. libcurl ignores CURLOPT_RESOLVE when a proxy is in use, and PHP's curl honours the http_proxy, https_proxy and all_proxy environment variables by default. In an environment that sets any of them the request therefore went out unpinned, and checkOperationality() still reported success, because its DNS check does not go through curl at all.

Measured on libcurl 8.21.0 with the same option value this code builds:

  • without a proxy variable: Trying 203.0.113.10:80..., the pin is used
  • with http_proxy set: Uses proxy env variable http_proxy, Trying 127.0.0.1:9..., the pinned address is never contacted

The commit clears CURLOPT_PROXY for that request. It only takes effect when the user explicitly enables require-public-ip, and with a proxy in the path the guarantee cannot be honoured anyway, so failing open silently seemed like the worse of the two options. Happy to change it to a hard failure with a clear message instead if you prefer that.

One more thing worth recording, in case it looks like an oversight: the IPv6 addresses are deliberately passed without square brackets. Brackets are accepted from libcurl 7.57.0 onwards, but versions below that reject a bracketed entry and skip it silently, which would reintroduce the unpinned request on exactly the old versions this option still supports. There is a comment in the code so it does not get "corrected" later.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

"require-public-ip" checks the host but not the connection

1 participant