Skip to content

fix(migrations): validate source endpoints - #14036

Open
abnegate wants to merge 11 commits into
1.9.xfrom
fix/migration-endpoint-ssrf-1.9.x
Open

abnegate wants to merge 11 commits into
1.9.xfrom
fix/migration-endpoint-ssrf-1.9.x

Conversation

@abnegate

@abnegate abnegate commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

What

GET /v1/migrations/appwrite/report and POST /v1/migrations/appwrite checked the source endpoint only with Utopia\Validator\URL. They now use a migrations Endpoint validator built on a new PublicURL validator (ported from main): 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) and PublicURL::getResolve() are ported from main. They return CURLOPT_RESOLVE entries (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, throwing general_argument_invalid with Invalid `endpoint`: ... when it fails.
  • The migrations worker calls $source->setResolver((new Endpoint())->resolve(...)) in processSource(), before report(), unless the endpoint is the internal http://{_APP_MIGRATION_HOST}/v1. The internal endpoint is built by one helper shared with processMigration() and authenticateSource(); the early validation in processMigration() stays.
  • GET /v1/migrations/appwrite/report sets the same resolver on the source it builds, before report().
  • Every source request, through Target::call() or the SDK client, now validates its URL and connects only to the checked addresses.
  • PublicHostname::resolve() uses Swoole's native getaddrinfo() inside hooked coroutines, ported from main (c2c72a1). The 1.9.x image (appwrite/base:1.4.3) runs Swoole 6.2.0, where the hooked dns_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/migration is required as dev-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 26 Client::call() override for the Appwrite source. Only that package changes in composer.lock. Switch it to 1.14.* once 1.14.2 is tagged.

Validator corrections this relies on

  • PublicHostname treats IPv4-compatible IPv6 (::/96, e.g. ::7f00:1) and site-local (fec0::/10) addresses as reserved.
  • PublicURL refuses URLs with userinfo or backslashes, where URL parsers and curl can disagree on the host.
  • Numeric IPv4 spellings (2130706433, 0x7f.1, 127.1) are never treated as allowed hostnames.
  • Endpoint reports one fixed description, so a rejection does not reveal whether a host exists or what it resolves to.

Opt-in allowlist

_APP_MIGRATIONS_ALLOWED_HOSTS takes 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.com does not allow evil-example.com or db.example.com. The default is empty, which allows public sources only. The development .env lists appwrite and the compose network 172.16.238.0/24 so the local stack can migrate from itself.

Effect on other callers

PublicHostname takes an optional Allowlist that 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 ::/96 and fec0::/10 addresses. PublicURL is new on this branch and is used only by the migrations Endpoint validator.

This is the backport of #14034. It stays compatible with PHP 8.3 (no array_all), and the changed files pass php -l and the network unit tests under php:8.3-cli.

Lookups in unhooked coroutines

PublicHostname::resolve() uses Swoole\Coroutine\System::getaddrinfo() only when the coroutine hooks network functions (SWOOLE_HOOK_NET_FUNCTION), the only case where Swoole 6.2's dns_get_record() retains ~140 KiB per lookup. Elsewhere it keeps the blocking dns_get_record(). getaddrinfo() always yields, and the 1.9.x and 2.0.x HTTP servers set no hook_flags, so GraphQL's Promise::then(), which waits in an unhooked usleep() 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 enable SWOOLE_HOOK_ALL and keep the native lookup.

Tests

  • Unit: 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.
  • Unit (pinning): PublicHostnameTest checks the getResolve() 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. PublicURLTest checks 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 for localhost, which failed Tests / Unit in CI. EndpointTest covers resolve() returning entries and throwing for every rejected endpoint. The coroutine cases check resolution under SWOOLE_HOOK_ALL and that a lookup retains under 4 KiB (the memory case fails without the getaddrinfo() change on Swoole 6.2). All fail without the change.
  • E2E: testGetAppwriteReportWithSubnetEndpoint and testCreateAppwriteMigrationWithSubnetEndpoint report and migrate from http://appwrite.test/v1. The development .env no longer lists appwrite.test by name, so the validator resolves it to traefik's address, admits it because .env lists the compose network 172.16.238.0/24, and the source connects to that address through the CURLOPT_RESOLVE entry. appwrite.test is _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. testGetAppwriteReport reports from the shared source project, and the rejection test now also refuses http://localhost/v1.
  • Unit: PublicHostnameTest::testResolvesWithoutYieldingInsideUnhookedCoroutine fails if a lookup in an unhooked coroutine yields.
  • E2E: testAppwriteMigrationRejectsPrivateEndpoints checks that report and create return general_argument_invalid for 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/Network and the migrations validator tests pass under PHP 8.3 (php:8.3-cli with Swoole 6.2.3) and in appwrite/base:1.4.3.

🤖 Generated with Claude Code

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@hansi-codes

hansi-codes Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

🟢 Tier S · Ready to merge

The incremental changes preserve subnet validation coverage using the compose-network alias, with no concrete problems found.

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.

Verdict New comments Fixed Still open
✅ Approved 0 0 0
📂 Walkthrough · 16
File Change
.env, app/config/variables.php, docker-compose.yml Define, document, and forward the migration allowlist; development allows appwrite and the compose subnet.
composer.json, composer.lock Select the migration dependency branch providing source request controls.
src/Appwrite/Network/Allowlist.php, src/Appwrite/Network/Subnet.php Add exact hostname and IPv4/IPv6 CIDR matching.
src/Appwrite/Network/Validator/PublicHostname.php Validate resolved addresses, expose curl pinning entries, and use native DNS only in network-hooked coroutines.
src/Appwrite/Network/Validator/PublicURL.php Validate HTTP(S) URLs and expose port-specific checked-address entries.
src/Appwrite/Platform/Modules/Migrations/Validator/Endpoint.php Apply the configured allowlist and resolve endpoints with fixed rejection messages.
src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php Validate submitted source endpoints with the migration endpoint validator.
src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php Validate and attach a resolver before requesting source reports.
src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Update.php Remove the unsupported dateUpdated attribute from retry updates.
src/Appwrite/Platform/Workers/Migrations.php Revalidate stored external endpoints and attach source resolution while exempting the generated internal endpoint.
src/Appwrite/SDK/Specification/Format/OpenAPI3.php Represent the migration endpoint validator as a URL schema.
tests/e2e/Services/Migrations/MigrationsBase.php Cover endpoint rejection, stored-endpoint retries, and successful reports and migrations through the subnet-allowed web endpoint.
tests/unit/Network/AllowlistTest.php, tests/unit/Network/SubnetTest.php Test hostname and subnet parsing and matching.
tests/unit/Network/Validators/LoopbackHostname.php, tests/unit/Network/Validators/PublicHostnameTest.php Test reserved addresses, pinning, coroutine DNS memory behavior, and non-yielding unhooked resolution.
tests/unit/Network/Validators/PublicURLTest.php Test URL restrictions, allowlists, and port-specific resolution.
tests/unit/Platform/Modules/Migrations/Validator/EndpointTest.php Test endpoint restrictions, allowlist configuration, and checked-address resolution.

Reviewed the commits since 382d6f0 · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Tier D · 1 blocking finding to address. Summary

Comment thread src/Appwrite/Network/Validator/PublicURL.php
Comment thread src/Appwrite/Network/Validator/PublicURL.php Outdated
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

✨ Benchmark results

Comparing 1.9.x (before) to fix/migration-endpoint-ssrf-1.9.x (after).

Before

Scenario P50 (ms) P95 (ms) Requests RPS
API total 13.59 121.92 185 34.94
Account 27.74 151.85 35 7.29
TablesDB 12.21 19 35 8.51
Storage 11.58 115.56 75 17.93
Functions 23.78 31.63 40 9.66

After

Scenario P50 (ms) P95 (ms) Requests RPS
API total 12.93 123.99 185 34.98
Account 26.89 152.53 35 7.32
TablesDB 11.89 18.42 35 8.53
Storage 11.13 117.41 75 17.95
Functions 23.94 35.21 40 9.73

Delta

Scenario P95 delta (ms)
API total +2.08
Account +0.68
TablesDB -0.58
Storage +1.85
Functions +3.59
Top API waits
API request Max wait (ms)
account.sessions.email.create 532.05
account.password.update 152.41
account.create 140.75

@abnegate
abnegate force-pushed the fix/migration-endpoint-ssrf-1.9.x branch from b097b88 to d7bfc6f Compare October 1, 2026 04:33

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

abnegate and others added 2 commits October 1, 2026 17:37
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>
@abnegate
abnegate force-pushed the fix/migration-endpoint-ssrf-1.9.x branch from d7bfc6f to 7b44027 Compare October 1, 2026 04:39

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

🔄 PHP-Retry Summary

Flaky tests detected across commits:

Commit 7b44027 - 1 flaky test
Test Retries Total Time Details
TablesDBConsoleClientTest::testCreatedAfter 1 240.68s Logs
Commit 4d51089 - 1 flaky test
Test Retries Total Time Details
FunctionsServerTest::testCreateExecution 1 86ms Logs

abnegate and others added 5 commits October 1, 2026 22:38
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>

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

abnegate and others added 4 commits October 2, 2026 16:17
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>

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.

1 participant