Skip to content

fix(migrations): validate source endpoints - #14034

Open
abnegate wants to merge 10 commits into
mainfrom
fix/migration-endpoint-ssrf
Open

abnegate wants to merge 10 commits into
mainfrom
fix/migration-endpoint-ssrf

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 PublicURL: 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.

Connection pinning

The report route and the worker pass (new Endpoint())->resolve(...) to the Appwrite source through setResolver(); the worker skips its own internal http://{_APP_MIGRATION_HOST}/v1 endpoint, which one getInternalEndpoint() helper now builds for processMigration(), processSource() and authenticateSource(). Endpoint::resolve(string $url) validates each request URL and returns the CURLOPT_RESOLVE entries for the addresses it checked, or throws general_argument_invalid. Every source request is therefore validated again and connects only to the checked addresses, and the source no longer follows redirects.

Dependency

setResolver() comes from utopia-php/migration#230, the backport cut from 2.0.8, which is the version main locks today. composer.json requires dev-feat/source-request-controls-2.0.x as 2.0.9 until 2.0.9 is tagged, and the lock change covers only that package. Switch it back to ^2.0.9 before merge. utopia-php/migration#229, the main-line change, builds on Appwrite PHP SDK 30, which imports Utopia\Client; this repository's packages/client only provides Utopia\Client\Client, so that version cannot load here until the SDK or the package catches up.

Subnet and Allowlist live in packages/validators, which the root autoload loads directly, so no utopia-php/validators release is needed for this PR; the package CHANGELOG.md lists them under Unreleased for the next mirror release.

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 and PublicURL take an optional Allowlist that defaults to empty. The other callers keep their behaviour except for the two corrections above: ::/96 and fec0::/10 addresses are now rejected (avatars favicon/image/screenshot, webhooks worker), and URLs with userinfo or backslashes are now rejected (avatars favicon/image/screenshot url).

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. EndpointTest covers resolve() returning the checked addresses inside a hooked coroutine, returning none for IP literals, and throwing for every rejected endpoint. All the new cases 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, migrations-validator and migrations-worker unit tests pass.

🤖 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 PR validates Appwrite migration source endpoints as public HTTP(S) URLs, with an optional hostname/CIDR allowlist, worker revalidation, and connection pinning. It adds reusable allowlist/subnet validators, tightens reserved-address and ambiguous-URL checks, preserves URL metadata in SDK specifications, and adds unit and e2e coverage. It also updates Composer orchestration wiring and the ClickHouse configuration mount.

Latest changes: The latest commits remove appwrite.test from the development hostname allowlist and use the shared web endpoint in subnet migration tests, exercising DNS resolution and subnet authorization for that hostname.

Verdict New comments Fixed Still open
✅ Approved 0 0 0
📂 Walkthrough · 19
File Change
.env Allows the local appwrite hostname and development subnet, leaving appwrite.test to subnet validation.
app/config/variables.php Documents the optional migration hostname/CIDR allowlist.
composer.json; composer.lock Selects the migration request-controls dependency and wires the absorbed orchestration package into Composer.
docker-compose.yml Passes migration allowlist configuration to services and mounts the ClickHouse configuration.
packages/validators/CHANGELOG.md; packages/validators/README.md Documents the reusable Allowlist and Subnet validators.
packages/validators/src/Validator/Allowlist.php Adds exact normalized hostname and subnet address matching.
packages/validators/src/Validator/Subnet.php Adds IPv4/IPv6 CIDR and single-address validation.
packages/validators/tests/AllowlistTest.php; packages/validators/tests/SubnetTest.php Tests hostname matching, subnet boundaries, address families, and invalid inputs.
src/Appwrite/Network/Validator/PublicHostname.php Supports allowlists, rejects additional reserved IPv6 ranges, and selects DNS resolution based on coroutine hooks.
src/Appwrite/Network/Validator/PublicURL.php Supports allowlisted hosts/subnets and rejects credentials and backslashes.
src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php Validates submitted migration source endpoints with Endpoint.
src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Report/Get.php Validates report endpoints and installs a validating source resolver.
src/Appwrite/Platform/Modules/Migrations/Validator/Endpoint.php Parses allowlists, provides fixed rejection messages, and returns checked resolution entries.
src/Appwrite/Platform/Workers/Migrations.php Revalidates stored endpoints, installs external-source resolvers, and centralizes the internal endpoint exemption.
src/Appwrite/SDK/Specification/Format/OpenAPI3.php Preserves URL schema metadata for the endpoint validator.
tests/e2e/Services/Migrations/MigrationsBase.php Covers private endpoint and legacy retry rejection plus successful reports and migrations through the subnet-authorized shared web endpoint.
tests/unit/Network/Validators/PublicHostnameTest.php Covers reserved addresses, allowlists, and non-yielding resolution in unhooked coroutines.
tests/unit/Network/Validators/PublicURLTest.php Covers ambiguous URLs and allowlist integration.
tests/unit/Platform/Modules/Migrations/Validator/EndpointTest.php Tests endpoint policy and resolver output using string parsing and subnet checks.

Reviewed the commits since 294dfd3 · 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/Platform/Workers/Migrations.php
Comment thread src/Appwrite/Network/Validator/PublicURL.php Outdated
Comment thread tests/unit/Platform/Workers/MigrationsTest.php Outdated
Comment thread src/Appwrite/Network/Subnet.php Outdated
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → fix/migration-endpoint-ssrf (after).

Metric Before After Change
🚀 Requests/sec 212.5 203.91 ⚪ -4%
⏱️ Latency P50 81.48 ms 84.64 ms ⚪ +3.9%
⏱️ Latency P95 188.9 ms 201.76 ms 🔴 +6.8%
Per-scenario breakdown & investigation details

Metrics below reflect the current branch (after). Δ P95 compares against the base.

Scenario P50 (ms) P95 (ms) Requests RPS Δ P95 (ms)
API total 84.64 201.76 12,540 203.91 +12.86
Account 162.05 310.87 660 11.44 +24.4
TablesDB 80.97 153.26 6,820 113.11 +8.12
Storage 78.25 167.59 3,300 56.51 +1.24
Functions 129.86 253.59 1,760 30.75 +19.44

Top API waits (after)

API request Max wait (ms)
account.prefs.update 505.17
functions.create 482.93
account.name.update 474.71
functions.delete 396.12
functions.variables.create 388.91

@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 and PublicURL now take
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.

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 PublicURL: 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 branch from fb1f75a to 576790f 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.

abnegate and others added 5 commits October 1, 2026 22:31
…hp/validators

Subnet and Allowlist are generic network primitives, so they belong in the
validators package rather than src/Appwrite. Both are now Utopia validators:
Subnet matches an address against one CIDR range or single address, and
Allowlist matches exact hostnames or addresses inside its subnets.

Reading _APP_MIGRATIONS_ALLOWED_HOSTS stays in the migrations Endpoint, since
the comma-separated format and the rule that unparsable entries are ignored
are Appwrite configuration policy. PublicURL now only asks whether a resolved
address is listed; the per-address public-or-listed rule is enforced once, by
the PublicHostname check that already follows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
…ated addresses

The endpoint was validated once, and then the Appwrite source resolved the
host again for every request it sent and followed redirects, so the
checked addresses were not necessarily the ones it connected to.

Apply the 2.0.x and 1.9.x change here. Endpoint::resolve() validates a
request URL and returns the CURLOPT_RESOLVE entries for the addresses it
checked. The report route and the worker pass it to the Appwrite source
through setResolver(), so every source request is validated again and
connects only to those addresses. The worker skips its own internal
_APP_MIGRATION_HOST endpoint, which now comes from one helper shared with
processMigration() and authenticateSource().

setResolver() comes from utopia-php/migration#230, the backport cut from
2.0.8, the version main locks today. It overrides the SDK 27 client's
call(). utopia-php/migration#229, the main-line change, builds on Appwrite
SDK 30, which imports Utopia\Client; this repository's packages/client
only provides Utopia\Client\Client, so that change cannot load here yet.
The requirement points at the backport branch until 2.0.9 is tagged, and
the lock change covers only that package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PublicHostname::resolve() uses 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, and a caller that waits for it in an unhooked
usleep() loop never lets the reactor resume it.

The 1.9.x and 2.0.x backports of this PR hit exactly that: their HTTP
servers set no hook_flags, GraphQL's Promise::then() waits in a usleep()
loop, and every GraphQL avatar request that fetches a URL hung.

The retained memory only comes from the SWOOLE_HOOK_NET_FUNCTION hook, so
resolve natively only when that hook is active, and otherwise make the
blocking dns_get_record() call, which neither yields nor retains memory
without the hook. Main's HTTP server and the workers enable
SWOOLE_HOOK_ALL, so they keep the native lookup; this keeps the three
lines identical. 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>
… 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>

@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 A · Looks good to merge. Summary

Comment thread tests/unit/Platform/Modules/Migrations/Validator/EndpointTest.php Outdated
abnegate and others added 2 commits October 2, 2026 16:20
composer.lock: kept main's lock and re-applied only the utopia-php/migration backport branch with composer update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md allows no new regular expressions. Split the CURLOPT_RESOLVE entry on its separators, compare the host and port exactly, and check every address with the Subnet validator for 127.0.0.0/8 or ::1.

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

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