Check and pin a shop's address on every request, not just on the form - #17
Merged
Merged
Conversation
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>
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.
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.
HostResolverasked 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'sforeachwithout 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:64:ff9b::a00:110.0.0.1224.0.0.1192.0.0.1100.64.0.110.0.0.593.184.215.14What changed
ProductSyncManager solved this a while ago, and the code is worth having twice rather than solving it differently a second time.
HostResolver,PublicHostGuardandUnsafeDestinationExceptionare copied from it unchanged, under the sameApp\Services\Networknamespace;TestCasegains the same resolver stub.PublicShopUrlnow 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 curlCURLOPT_RESOLVEso 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, whichUnsafeDestinationExceptionextends.WOOCOMMERCE_ALLOW_PRIVATE_HOSTSturns all of this off for a fake shop on a.testdomain during local development — on in local, nowhere else.Notes
tests/Feature/Shops/ShopDestinationTest.phpcovers 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.203.0.113.10: TEST-NET-3 is documentation space, which the wider blocklist correctly refuses.validShopData()moved fromShopTest.phpintotests/Pest.php, replacing the unusedfunction something()stub, so more than one file can use it.🤖 Generated with Claude Code