Skip to content

feat: add resolver and redirect controls for source requests (1.14.x) - #231

Open
abnegate wants to merge 2 commits into
1.14.xfrom
feat/source-request-controls-1.14.x
Open

abnegate wants to merge 2 commits into
1.14.xfrom
feat/source-request-controls-1.14.x

Conversation

@abnegate

@abnegate abnegate commented Oct 1, 2026

Copy link
Copy Markdown
Member

Backport of #229 to the 1.14.x line (PHP 8.2+, appwrite/appwrite ^26). The Appwrite SDK client is covered by overriding SDK 26 Client::call() instead of SDK 30's createHttpClient(), and the SDK is not bumped.

What

Target::call() gains three controls:

  • HTTP and HTTPS only. CURLOPT_PROTOCOLS and CURLOPT_REDIR_PROTOCOLS are set to CURLPROTO_HTTP | CURLPROTO_HTTPS, so an endpoint or redirect with any other scheme (file, gopher, dict, ...) is refused by curl. The bitmask options are used because CURLOPT_PROTOCOLS_STR needs PHP 8.3.
  • 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(). SDK 26 does its cURL work inline in Client::call(), so the source now builds its client from Sources\Appwrite\Client, a subclass that overrides call() with the SDK 26 behaviour except that:

  • 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 each request URL, and its CURLOPT_RESOLVE entries apply to that request.

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. Appwrite 1.9.x stays on PHP 8.3 and utopia-php/migration 1.14, so it needs these controls without the SDK 30 upgrade.

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. This also applies to the Appwrite source's SDK requests, which SDK 26 previously followed. Without a resolver, connections resolve exactly as before.

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 and receives each request URL;
  • 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. With the call() override disabled, 4 of the 5 AppwriteClientTest cases fail; the location case guards existing behaviour. The unit suite passes on PHP 8.2, 8.3 and 8.5, and pint --test and phpstan --level 3 pass.

🤖 Generated with Claude Code

abnegate and others added 2 commits October 1, 2026 22:41
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.

This is the 1.14.x backport of #229. Protocols are
restricted with the CURLOPT_PROTOCOLS bitmask because CURLOPT_PROTOCOLS_STR
needs PHP 8.3 and this line supports PHP 8.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 26 does its cURL work inline in Client::call() and follows redirects
for every response type except 'location', so the source now uses a
Client subclass that overrides call() with the same behaviour except that
it never follows redirects, allows only http and https, and passes the
resolver's CURLOPT_RESOLVE entries for each request URL 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.

This keeps the 1.14.x line on SDK 26, since SDK 30 needs PHP 8.5.

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.

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