Skip to content

feat: add resolver and redirect controls for source requests - #229

Merged
abnegate merged 3 commits into
mainfrom
feat/source-request-controls
Oct 15, 2026
Merged

abnegate merged 3 commits into
mainfrom
feat/source-request-controls

Conversation

@abnegate

@abnegate abnegate commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

What

Target::call() gains three controls:

  • HTTP and HTTPS only. CURLOPT_PROTOCOLS_STR and CURLOPT_REDIR_PROTOCOLS_STR are set to http,https, so an endpoint or redirect with any other scheme (file, gopher, dict, ...) is refused by curl.
  • No redirect following by default. A 3xx response throws an exception with the status as its code instead of being returned as the body. setFollowRedirects(true) turns following back on.
  • Optional resolver. setResolver(?Closure) takes a callback that receives each request URL and returns the CURLOPT_RESOLVE entries (host:port:address[,address]) the connection must use, so curl connects to addresses the consumer has already validated instead of resolving the host again. The callback can throw to refuse a URL.

Appwrite source SDK client

Most Appwrite source requests go through the Appwrite PHP SDK client rather than Target::call(). The source now builds its client from Sources\Appwrite\Client, a subclass of the SDK 30 Client that overrides createHttpClient():

  • redirects are never followed; a 3xx raises an AppwriteException with the status as its code, as Target::call() does (responseType: 'location' still returns the Location header);
  • only http and https are allowed;
  • setResolver() takes the same callback as Target, called with the endpoint URL, and its CURLOPT_RESOLVE entries apply to every SDK request. The client is rebuilt when the endpoint changes.

Sources\Appwrite::setResolver() passes the resolver to both Target::call() and the SDK client.

Why

Consumers that validate a source endpoint before running a migration need every request to reach the address they approved.

Behaviour change

Redirects are no longer followed unless a consumer opts in, so an endpoint that answers with a redirect (for example http:// to https://) now fails with that status instead of being followed. Without a resolver, connections resolve exactly as before.

Dependencies

  • appwrite/appwrite ^27.0 → ^30.0. SDK 29+ needs utopia-php/client 0.4, which pulls utopia-php/storage 4.2, utopia-php/database 7.4 and utopia-php/validators 1.0 forward in the lock. utopia-php/client ^0.4.1 is now required directly, since the subclass builds its cURL adapter.
  • SDK 30 sends X-Appwrite-Response-Format: 2.0.0.
  • phpstan/phpstan 1.* → ^2.1, because PHPStan 1 can't parse the SDK's PHP 8.5 syntax and reports every SDK class as unknown. PHPStan 2 also flagged two stale ignores, a malformed callable docblock and string content-length arithmetic, all fixed.

Tests

tests/Migration/Unit/General/TargetRequestTest.php runs against a local php -S fixture:

  • a 302 is not followed by default (one request reaches the fixture), and is followed with setFollowRedirects(true);
  • a followed redirect to file:// is refused;
  • a resolver pins pinned.invalid to 127.0.0.1 and receives the full request URL;
  • a resolver that throws stops the request before it is sent;
  • gopher://, dict:// and file:// endpoints are refused.

tests/Migration/Unit/Sources/AppwriteClientTest.php uses the same fixture for the SDK client:

  • a 302 raises AppwriteException with code 302, and only one request reaches the fixture;
  • responseType: 'location' still returns the redirect target;
  • a resolver pins pinned.invalid for every request, is called once with the endpoint, and runs again after setEndpoint();
  • a resolver that throws stops the request before it is sent;
  • Sources\Appwrite::setResolver() reaches the SDK client: report() sends GET /v1/users to the pinned host.

With Target.php reverted, all 8 TargetRequestTest cases fail. Without the createHttpClient() override and the forwarding in setResolver(), 5 of the 6 AppwriteClientTest cases fail. The location case guards existing behaviour. The unit suite, pint --test and phpstan --level 3 pass locally.

🤖 Generated with Claude Code

Consumers that validate a source endpoint before a migration need every
request to reach the address they approved. Target::call followed
redirects to any host and let libcurl use every protocol it was built
with, so the request could end up somewhere the endpoint check never saw.

Requests now use http and https only, for the request and any redirect.
Redirects are no longer followed by default, and a 3xx response raises an
exception with the status as its code instead of being returned as the
body; setFollowRedirects(true) restores following. setResolver() takes a
callback that receives each request URL and returns the CURLOPT_RESOLVE
entries the connection must use, so curl connects to pre-validated
addresses instead of resolving the host again. The callback can throw to
refuse a URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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.

Most Appwrite source requests go through the Appwrite PHP SDK client, not
Target::call, so a resolver set on the source left those requests free to
resolve the host again and follow redirects anywhere.

SDK 30 builds its transport in an overridable createHttpClient(), so the
source now uses a Client subclass that never follows redirects, allows only
http and https, and passes the resolver's CURLOPT_RESOLVE entries for the
endpoint to cURL. A 3xx raises an AppwriteException with the status as its
code, matching Target::call; responseType 'location' still returns the
Location header. setResolver() on the source forwards to the client.

SDK 30 requires utopia-php/client 0.4 and PHP 8.5 syntax, which pulls
utopia-php/storage, database and validators forward and needs PHPStan 2 to
parse. PHPStan 2 also flagged two stale ignores, a malformed callable
docblock and string content-length arithmetic, fixed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 added a commit to appwrite/appwrite that referenced this pull request Oct 1, 2026
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>
abnegate added a commit to appwrite/appwrite that referenced this pull request Oct 2, 2026
…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>
Main moved Target::call() and the Appwrite SDK onto utopia-php/client
0.6, so the resolver and redirect controls are rebuilt on that transport:
Target builds a dedicated client for resolver-pinned requests (with the
resolve entries and proxies disabled) and keeps one shared client
otherwise, and the source client imports Utopia\Client\Client. The
transport now refuses non-HTTP schemes before curl runs, so the
protocol test asserts the transport refusal rather than curl's wording.

Also brings over the 2.0.x fix that keeps resolver-pinned requests off
environment proxies, with its regression tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abnegate

Copy link
Copy Markdown
Member Author

Merged main in (2f1eac6). Main moved Target::call() and the Appwrite SDK onto utopia-php/client 0.6, so the conflict resolution rebuilds this PR on that transport rather than keeping the raw-curl version:

  • Target keeps one shared client for unpinned requests. A request with a resolver set gets its own client carrying CURLOPT_RESOLVE and CURLOPT_PROXY => ''. setFollowRedirects() drops the cached client so the new setting takes effect.
  • Sources\Appwrite\Client now imports Utopia\Client\Client (0.6 renamed it from Utopia\Client) and also disables proxies while a resolver is set. This brings over the 2.0.x fix aca9a75 that feat: add resolver and redirect controls for source requests #229 was missing, plus its two regression tests.
  • The 0.6 transport refuses non-HTTP schemes before curl runs, and its message no longer contains "protocol". testOnlyHttpProtocolsAreAllowed now asserts that the refusal comes from the transport (ClientExceptionInterface as the previous exception) instead of matching curl's wording.

Behaviour to review: Target::call() no longer follows redirects by default. Main had withFollowRedirects() on, and this PR has always meant to turn it off. Sources that need redirects followed must call setFollowRedirects(true).

The public API used by appwrite/appwrite#14034 is unchanged: setResolver(?\Closure(string): array<string>) on the source.

Locally on the merged head: Pint passes, PHPStan level 3 passes, and tests/Migration/Unit passes. The timeout tests needed a clean php.ini because of a broken local extension.

@greptileai review

@abnegate
abnegate merged commit c8b9ba5 into main Oct 15, 2026
3 checks passed
@abnegate
abnegate deleted the feat/source-request-controls branch October 15, 2026 03:57
abnegate added a commit to appwrite/appwrite that referenced this pull request Oct 15, 2026
…lver

utopia-php/migration#229 landed the source resolver and redirect controls
on 3.x main, so the source takes CURLOPT_RESOLVE pins from Endpoint again
instead of a destinations policy from a separate branch. Require migration
main until it is tagged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abnegate added a commit to appwrite/appwrite that referenced this pull request Oct 15, 2026
3.2.0 is the first tag carrying the source request resolver from
utopia-php/migration#229, so the dev-main alias is no longer needed.

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