Conversation
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
🟢 Tier S · Ready to merge
Adds public HTTP(S) endpoint validation and DNS address pinning for Appwrite migration reports and worker source requests, with an opt-in hostname/CIDR allowlist and an exemption for worker-generated internal endpoints. Tightens reserved-address and ambiguous-URL checks, updates the migration dependency, and adds unit and E2E coverage. The development configuration now exercises subnet-based validation for appwrite.test rather than allowlisting that hostname. Latest changes: The newest commits remove appwrite.test from the development hostname allowlist and switch the subnet migration tests from traefik to the shared web endpoint.
📂 Walkthrough · 16
Reviewed the commits since |
✨ Benchmark resultsComparing 1.9.x (before) to fix/migration-endpoint-ssrf-1.9.x (after). Before
After
Delta
Top API waits
|
b097b88 to
d7bfc6f
Compare
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
Some callers need to reach destinations that are deliberately private, such as another service on the same network, while still refusing every other private or reserved address. PublicHostname now takes an optional Allowlist of exact hostnames and IPv4/IPv6 subnets. A listed hostname is accepted without a lookup, an IP in a listed subnet is accepted, and a hostname is accepted when every address it resolves to is public or listed. Hostnames never match by suffix, and numeric spellings such as 2130706433 or 127.1 are never treated as listed hostnames. The allowlist defaults to empty, so existing callers keep their current behaviour. Resolution is late-static-bound so tests can substitute it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Appwrite migration report and create endpoints accepted any URL for the source endpoint, so the server could be pointed at loopback, private, link-local or other reserved addresses, and the migrations worker used whatever endpoint had been stored. Both params now use an Endpoint validator built on a new PublicURL validator: http or https only, a known public domain or public IP, and every resolved address public. The worker validates a stored endpoint again before it builds the source, so migrations queued earlier or retried are held to the same rule; the internal endpoint the worker writes itself is exempt. PublicHostname now also treats IPv4-compatible IPv6 addresses (::/96, e.g. ::7f00:1) and the deprecated site-local range (fec0::/10) as reserved, and PublicURL refuses URLs with userinfo or backslashes, where URL parsers and curl can disagree on the host. The Endpoint validator reports one fixed description, so a rejection does not reveal whether a host exists or what it resolves to. _APP_MIGRATIONS_ALLOWED_HOSTS takes comma-separated hostnames and IPv4 or IPv6 CIDR ranges for sources that are deliberately private, such as an instance on the same network. Hostnames match exactly. It is empty by default, which allows public sources only; the development .env lists appwrite and appwrite.test so the local stack can migrate from itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d7bfc6f to
7b44027
Compare
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
Worker behaviour is covered by e2e tests, not worker unit tests. The create route now refuses private endpoints, so the e2e test stores an Appwrite migration directly, the way a job created before validation would look, retries it through the API and checks that the real worker records the endpoint failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retrying a failed migration set dateUpdated, which is not an attribute of the migrations collection. On this branch the project database rejects unknown attributes, so the worker's first write of the retried job threw a structure error and the migration was never updated: it stayed failed with its old errors, whatever the retry did. The stored endpoint e2e test retries a migration and caught this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A caller that validates a URL and then fetches it resolves the host twice, so DNS can answer differently for the fetch than it did for the check. PublicHostname now keeps the addresses it approved and getResolve(port) returns them as CURLOPT_RESOLVE entries; PublicURL forwards them for the URL's port (80 or 443 by default). IP literals and allowlisted hostnames yield no entries, since there is nothing resolved to pin. Callers that pin every request resolve once per request, and under SWOOLE_HOOK_ALL Swoole 6.2 routes dns_get_record() through a RemoteObject client that is created per call and never released (~140 KiB each). The 1.9.x image runs Swoole 6.2, so lookups inside a coroutine now use Swoole's native getaddrinfo(), as main already does. Ported from main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The worker validated a stored Appwrite endpoint once, but the source then resolved the host again for every request and the SDK client followed redirects, so a host that changed its DNS answer after the check, or redirected, could still reach a private address. Endpoint::resolve() validates a URL and returns the CURLOPT_RESOLVE entries for it, throwing the same invalid-endpoint error as the early check. processSource() sets it as the Appwrite source's resolver unless the endpoint is the worker's own internal one, so every source request is validated and connects to the addresses that were checked. The internal endpoint is now built by one helper. utopia-php/migration points at the 1.14.x backport branch of utopia-php/migration#229 (resolver support and no redirect following for source requests on SDK 26) until 1.14.2 is tagged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The report route validates the endpoint parameter, but the source it builds resolved the host again for each request, leaving a DNS rebinding window between the check and report(). The source now uses Endpoint::resolve() as its resolver, as the worker does. The e2e suite had no successful report call, so testGetAppwriteReport reports from the local stack through the pinned path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PublicHostname::resolve() switched to Swoole\Coroutine\System::getaddrinfo() inside any coroutine, to avoid the ~140 KiB that Swoole 6.2 keeps for each hooked dns_get_record() call. getaddrinfo() always yields, even when the runtime hooks nothing. On 1.9.x and 2.0.x the HTTP server sets no hook_flags, so usleep() blocks there. GraphQL resolvers run each route in a child coroutine while Promise::then() waits for it in a usleep() loop. Once the avatars image, favicon and screenshot routes yielded inside the lookup, that loop never let the reactor resume them, and every GraphQL avatar request that fetches a URL hung until the client gave up (seven 120 s timeouts in the GraphQL E2E job on both branches). The retained memory only comes from the SWOOLE_HOOK_NET_FUNCTION hook, so resolve natively only when that hook is active. Workers and main's HTTP server, which enable SWOOLE_HOOK_ALL, keep the native lookup. Unhooked coroutines make the blocking dns_get_record() call, which neither yields nor retains memory without the hook. The regression test resolves inside a child of an unhooked coroutine and fails if control returns to the parent before the lookup finishes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PublicURLTest::testResolveUsesTheURLPort and EndpointTest::testResolveReturnsTheCheckedAddresses looked up localhost outside a coroutine, where PublicHostname uses dns_get_record(). That skips /etc/hosts, so the result depends on whether the resolver answers for localhost. In CI it did not, and Tests / Unit failed with "Hostname localhost does not resolve". Run both inside a SWOOLE_HOOK_ALL coroutine, as 2.0.x does, where the lookup goes through getaddrinfo() and reads /etc/hosts. The port cases move into testAcceptsHostnameResolvingIntoAllowedSubnetInsideCoroutine, and a subnet that excludes loopback is covered by testRejectsHostnameResolvingOutsideAllowedSubnetInsideCoroutine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… to end Every Appwrite migration test used appwrite.test, which the allowlist admits by name without resolving it, so no E2E request went through the resolved and pinned path. List the compose network (172.16.238.0/24) in the development allowlist and report and migrate from http://traefik/v1. traefik is not a listed hostname, so the validator resolves it, admits the address because it is inside the listed range, and the source connects to that address through the CURLOPT_RESOLVE entry. Each test uses its own source project so the user counts stay exact while other migration tests run in parallel. Also report from the allowlisted appwrite.test, as 1.9.x already does, and reject http://localhost/v1, a hostname that resolves outside every listed range. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI enables _APP_OPTIONS_ROUTER_PROTECTION, so requests to http://traefik/v1 reached the API through the pinned address and were refused with 401 because traefik is not a platform hostname. Stop listing appwrite.test by name in the development allowlist instead. It is _APP_DOMAIN, so the router accepts it, and the validator now resolves it to traefik's address inside the listed 172.16.238.0/24 range. The subnet tests use it, and every other Appwrite migration E2E test now takes the resolved and pinned path too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What
GET /v1/migrations/appwrite/reportandPOST /v1/migrations/appwritechecked the sourceendpointonly withUtopia\Validator\URL. They now use a migrationsEndpointvalidator built on a newPublicURLvalidator (ported frommain): http or https only, a known public domain or public IP, and every resolved address public.The migrations worker validates a stored Appwrite-source endpoint again before it builds the source, so migrations queued earlier or retried follow the same rule. The internal endpoint the worker writes itself (
http://{_APP_MIGRATION_HOST}/v1) is exempt.Pinning source requests to the validated addresses
Validating the endpoint once still left a DNS rebinding window: the source resolved the host again for every request, and the Appwrite SDK client followed redirects.
PublicHostname::getResolve(int $port)andPublicURL::getResolve()are ported frommain. They returnCURLOPT_RESOLVEentries (host:port:address[,address]) for the addresses the last successful check approved; IP literals and allowlisted hostnames return none.Endpoint::resolve(string $url)validates a URL and returns those entries, throwinggeneral_argument_invalidwithInvalid `endpoint`: ...when it fails.$source->setResolver((new Endpoint())->resolve(...))inprocessSource(), beforereport(), unless the endpoint is the internalhttp://{_APP_MIGRATION_HOST}/v1. The internal endpoint is built by one helper shared withprocessMigration()andauthenticateSource(); the early validation inprocessMigration()stays.GET /v1/migrations/appwrite/reportsets the same resolver on the source it builds, beforereport().Target::call()or the SDK client, now validates its URL and connects only to the checked addresses.PublicHostname::resolve()uses Swoole's nativegetaddrinfo()inside hooked coroutines, ported frommain(c2c72a1). The 1.9.x image (appwrite/base:1.4.3) runs Swoole 6.2.0, where the hookeddns_get_record()keeps ~140 KiB per lookup, and the resolver now looks up the host on every source request (see below for the unhooked case).Dependency:
utopia-php/migrationis required asdev-feat/source-request-controls-1.14.x as 1.14.2, the 1.14.x backport of utopia-php/migration#229 (utopia-php/migration#231):setResolver()on sources, http/https only, and no redirect following, including an SDK 26Client::call()override for the Appwrite source. Only that package changes incomposer.lock. Switch it to1.14.*once 1.14.2 is tagged.Validator corrections this relies on
PublicHostnametreats IPv4-compatible IPv6 (::/96, e.g.::7f00:1) and site-local (fec0::/10) addresses as reserved.PublicURLrefuses URLs with userinfo or backslashes, where URL parsers and curl can disagree on the host.2130706433,0x7f.1,127.1) are never treated as allowed hostnames.Endpointreports one fixed description, so a rejection does not reveal whether a host exists or what it resolves to.Opt-in allowlist
_APP_MIGRATIONS_ALLOWED_HOSTStakes comma-separated hostnames and IPv4/IPv6 CIDR ranges for sources that are deliberately private, such as another instance on the same network. A listed hostname, an IP in a listed range, or a hostname whose resolved addresses are all public or listed is accepted. Hostnames match exactly:example.comdoes not allowevil-example.comordb.example.com. The default is empty, which allows public sources only. The development.envlistsappwriteand the compose network172.16.238.0/24so the local stack can migrate from itself.Effect on other callers
PublicHostnametakes an optionalAllowlistthat defaults to empty, and its lookup is late-static-bound so tests can substitute it. Its other callers (avatars favicon/image/screenshot, webhooks worker) now also reject::/96andfec0::/10addresses.PublicURLis new on this branch and is used only by the migrationsEndpointvalidator.This is the backport of #14034. It stays compatible with PHP 8.3 (no
array_all), and the changed files passphp -land the network unit tests underphp:8.3-cli.Lookups in unhooked coroutines
PublicHostname::resolve()usesSwoole\Coroutine\System::getaddrinfo()only when the coroutine hooks network functions (SWOOLE_HOOK_NET_FUNCTION), the only case where Swoole 6.2'sdns_get_record()retains ~140 KiB per lookup. Elsewhere it keeps the blockingdns_get_record().getaddrinfo()always yields, and the 1.9.x and 2.0.x HTTP servers set nohook_flags, so GraphQL'sPromise::then(), which waits in an unhookedusleep()loop, never let a yielded avatars lookup resume: every GraphQL avatar request that fetches a URL hung (seven 120 s timeouts in the GraphQL E2E job). Main's HTTP server and the workers enableSWOOLE_HOOK_ALLand keep the native lookup.Tests
AllowlistTest,SubnetTest,PublicHostnameTest,PublicURLTest(exact host, IPv4 CIDR, IPv6 CIDR, non-match, lookalike hosts, numeric spellings, IPv6 forms, userinfo, backslashes),EndpointTest. All the new cases fail without the change.PublicHostnameTestchecks thegetResolve()format, that a curl request with the entries reaches the checked IPv4, IPv6 and trailing-dot hosts, that IP literals and allowlisted hostnames return none, and that entries reset after a rejection.PublicURLTestchecks the port (explicit, 80, 443) inside a hooked coroutine, where the lookup reads/etc/hosts; outside one,dns_get_record()depends on the resolver answering forlocalhost, which failedTests / Unitin CI.EndpointTestcoversresolve()returning entries and throwing for every rejected endpoint. The coroutine cases check resolution underSWOOLE_HOOK_ALLand that a lookup retains under 4 KiB (the memory case fails without thegetaddrinfo()change on Swoole 6.2). All fail without the change.testGetAppwriteReportWithSubnetEndpointandtestCreateAppwriteMigrationWithSubnetEndpointreport and migrate fromhttp://appwrite.test/v1. The development.envno longer listsappwrite.testby name, so the validator resolves it to traefik's address, admits it because.envlists the compose network172.16.238.0/24, and the source connects to that address through theCURLOPT_RESOLVEentry.appwrite.testis_APP_DOMAIN, so CI's router protection accepts it, and every other Appwrite migration E2E test takes the same pinned path. Each uses its own source project, so the user counts are exact.testGetAppwriteReportreports from the shared source project, and the rejection test now also refuseshttp://localhost/v1.PublicHostnameTest::testResolvesWithoutYieldingInsideUnhookedCoroutinefails if a lookup in an unhooked coroutine yields.testAppwriteMigrationRejectsPrivateEndpointschecks that report and create returngeneral_argument_invalidfor link-local, loopback, IPv6 loopback and non-http endpoints.Locally: lint, PHPStan and the network and migrations-validator unit tests pass. For the pinning commits, pint, PHPStan and rector on the changed files pass, and
tests/unit/Networkand the migrations validator tests pass under PHP 8.3 (php:8.3-cliwith Swoole 6.2.3) and inappwrite/base:1.4.3.🤖 Generated with Claude Code