Skip to content

Check and pin a shop's address on every request, not just on the form - #17

Merged
SourovCodes merged 1 commit into
mainfrom
fix/pin-shop-requests-to-public-addresses
Sep 20, 2026
Merged

SourovCodes merged 1 commit into
mainfrom
fix/pin-shop-requests-to-public-addresses

Conversation

@SourovCodes

Copy link
Copy Markdown
Member

Everything under a shop's URL is fetched by this server, with that shop's credentials attached, and the outcome is reported back in the interface. That makes the URL field a request forgery primitive, and until now the only thing in front of it was a validation rule that ran once, when the form was saved.

Three holes, easiest first

DNS was checked at save time and never again. Whoever controls the name can answer publicly while the form is validated and privately once the hourly sync runs. Nothing looked a second time.

HostResolver asked for A and AAAA in one combined query. Some resolvers fail that outright when the host has records of only one type — and an empty answer fell straight through the rule's foreach without failing anything, so an unresolvable host was accepted.

The blocklist was PHP's NO_PRIV_RANGE|NO_RES_RANGE, which is narrower than it looks:

address what it is old filter new guard
64:ff9b::a00:1 NAT64 wrapping 10.0.0.1 allowed blocked
224.0.0.1 multicast allowed blocked
192.0.0.1 IETF protocol assignments allowed blocked
100.64.0.1 carrier-grade NAT allowed blocked
10.0.0.5 private blocked blocked
93.184.215.14 genuinely public allowed allowed

What changed

ProductSyncManager solved this a while ago, and the code is worth having twice rather than solving it differently a second time. HostResolver, PublicHostGuard and UnsafeDestinationException are copied from it unchanged, under the same App\Services\Network namespace; TestCase gains the same resolver stub. PublicShopUrl now sits on the guard and keeps its own wording and its own rule that a shop lives at a domain name rather than a bare address.

The new part is WooCommerceClient::pinnedDestination(). Every call resolves the host again, refuses any non-public answer, refuses a host that will not resolve at all, and hands curl CURLOPT_RESOLVE so the third lookup cannot come back different from the one just approved. The proxy is cleared for the same reason — a proxy would resolve the shop on the far side, where the pin does not reach. Both attempts of the 401 key-and-secret fallback share one resolution.

A blocked destination is an outcome, not a crash: the connection test records it as Unreachable, and the sync already caught RuntimeException, which UnsafeDestinationException extends.

WOOCOMMERCE_ALLOW_PRIVATE_HOSTS turns all of this off for a fake shop on a .test domain during local development — on in local, nowhere else.

Notes

  • New tests/Feature/Shops/ShopDestinationTest.php covers the blocklist, the pin format on default and custom ports, DNS rebinding between validation and connection, unresolvable hosts, the blocked-sync outcome and the escape hatch.
  • One existing test moved off 203.0.113.10: TEST-NET-3 is documentation space, which the wider blocklist correctly refuses.
  • validShopData() moved from ShopTest.php into tests/Pest.php, replacing the unused function something() stub, so more than one file can use it.
  • 316 tests, PHPStan and Pint all green.

🤖 Generated with Claude Code

Everything under a shop's URL is fetched by this server, with that shop's
credentials attached, and the outcome is reported back in the interface.
That makes the URL field a request forgery primitive, and until now the
only thing standing in front of it was a validation rule that ran once,
when the form was saved.

Three holes, in order of how easy they are to walk through:

  - DNS is checked at save time and never again. Whoever controls the
    name can answer publicly while the form is validated and privately
    once the hourly sync runs. Nothing looked again.
  - HostResolver asked for A and AAAA in one combined query. Some
    resolvers fail that outright when the host has records of only one
    type, and an empty answer fell straight through the rule's foreach
    without failing anything. An unresolvable host was accepted.
  - The blocklist was PHP's NO_PRIV_RANGE|NO_RES_RANGE, which allows
    NAT64 (64:ff9b::/96, which wraps an IPv4 address and so reaches the
    private network under another name), multicast, carrier-grade NAT
    and the IETF protocol assignments.

ProductSyncManager solved this a while ago and the code is worth having
twice rather than solving it differently a second time: HostResolver,
PublicHostGuard and UnsafeDestinationException are copied from it
unchanged, under the same App\Services\Network namespace, and TestCase
gains the same resolver stub. PublicShopUrl now sits on the guard, and
keeps its own wording and its own rule that a shop lives at a domain
name rather than at a bare address.

The new part is WooCommerceClient::pinnedDestination(). Every call
resolves the host again, refuses any non-public answer, refuses a host
that will not resolve at all, and hands curl CURLOPT_RESOLVE so the
third lookup cannot come back different from the one just approved.
The proxy is cleared for the same reason: a proxy would resolve the
shop on the far side, where the pin does not reach. Both attempts of
the 401 key-and-secret fallback share one resolution.

A blocked destination is an outcome, not a crash: the connection test
records it as Unreachable, and the sync already caught RuntimeException,
which UnsafeDestinationException extends.

WOOCOMMERCE_ALLOW_PRIVATE_HOSTS turns all of this off for a fake shop
on a .test domain during local development, defaulting on in local and
nowhere else.

One existing test moved off 203.0.113.10: TEST-NET-3 is documentation
space, which the wider blocklist correctly refuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SourovCodes
SourovCodes merged commit 0e5a7d6 into main Sep 20, 2026
1 check passed
@SourovCodes
SourovCodes deleted the fix/pin-shop-requests-to-public-addresses branch September 20, 2026 10:55
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.

1 participant