diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 97f3696e67..a69e11b892 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -283,8 +283,6 @@ jobs: name: E2E Service Test (Dev Keys) runs-on: ubuntu-latest needs: setup - strategy: - fail-fast: false steps: - name: checkout uses: actions/checkout@v4 diff --git a/app/config/console.php b/app/config/console.php index 1de3a99370..b1e4299ce0 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -5,7 +5,7 @@ */ use Appwrite\Auth\Auth; -use Appwrite\Network\Validator\Origin; +use Appwrite\Network\Platform; use Utopia\Database\Helpers\ID; use Utopia\System\System; @@ -23,7 +23,7 @@ $console = [ [ '$collection' => ID::custom('platforms'), 'name' => 'Localhost', - 'type' => Origin::CLIENT_TYPE_WEB, + 'type' => Platform::TYPE_WEB, 'hostname' => 'localhost', ], // Current host is added on app init ], diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 06dd173ec4..915335f1c2 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -21,6 +21,7 @@ use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Hooks\Hooks; use Appwrite\Network\Validator\Email; +use Appwrite\Network\Validator\Redirect; use Appwrite\OpenSSL\OpenSSL; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -60,7 +61,6 @@ use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; -use Utopia\Validator\Host; use Utopia\Validator\Text; use Utopia\Validator\URL; use Utopia\Validator\WhiteList; @@ -1182,8 +1182,8 @@ App::get('/v1/account/sessions/oauth2/:provider') ->label('abuse-limit', 50) ->label('abuse-key', 'ip:{ip}') ->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'OAuth2 Provider. Currently, supported providers are: ' . \implode(', ', \array_keys(\array_filter(Config::getParam('oAuthProviders'), fn ($node) => (!$node['mock'])))) . '.') - ->param('success', '', fn ($clients, $devKey) => $devKey->isEmpty() ? new Host($clients) : new URL(), 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients', 'devKey']) - ->param('failure', '', fn ($clients, $devKey) => $devKey->isEmpty() ? new Host($clients) : new URL(), 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients', 'devKey']) + ->param('success', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) + ->param('failure', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) ->param('scopes', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) ->inject('request') ->inject('response') @@ -1282,7 +1282,6 @@ App::post('/v1/account/sessions/oauth2/callback/:provider/:projectId') ->inject('request') ->inject('response') ->action(function (string $projectId, string $provider, string $code, string $state, string $error, string $error_description, Request $request, Response $response) { - $domain = $request->getHostname(); $protocol = $request->getProtocol(); @@ -1779,8 +1778,8 @@ App::get('/v1/account/tokens/oauth2/:provider') ->label('abuse-limit', 50) ->label('abuse-key', 'ip:{ip}') ->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'OAuth2 Provider. Currently, supported providers are: ' . \implode(', ', \array_keys(\array_filter(Config::getParam('oAuthProviders'), fn ($node) => (!$node['mock'])))) . '.') - ->param('success', '', fn ($clients, $devKey) => $devKey->isEmpty() ? new Host($clients) : new URL(), 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients', 'devKey']) - ->param('failure', '', fn ($clients, $devKey) => $devKey->isEmpty() ? new Host($clients) : new URL(), 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients', 'devKey']) + ->param('success', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) + ->param('failure', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) ->param('scopes', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) ->inject('request') ->inject('response') @@ -1860,7 +1859,7 @@ App::post('/v1/account/tokens/magic-url') ->label('abuse-key', ['url:{url},email:{param-email}', 'url:{url},ip:{ip}']) ->param('userId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') ->param('email', '', new Email(), 'User email.') - ->param('url', '', fn ($clients, $devKey) => $devKey->isEmpty() ? new Host($clients) : new URL(), 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients', 'devKey']) + ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) ->param('phrase', false, new Boolean(), 'Toggle for security phrase. If enabled, email will be send with a randomly generated phrase and the phrase will also be included in the response. Confirming phrases match increases the security of your authentication flow.', true) ->inject('request') ->inject('response') @@ -3146,7 +3145,7 @@ App::post('/v1/account/recovery') ->label('abuse-limit', 10) ->label('abuse-key', ['url:{url},email:{param-email}', 'url:{url},ip:{ip}']) ->param('email', '', new Email(), 'User email.') - ->param('url', '', fn ($clients, $devKey) => $devKey->isEmpty() ? new Host($clients) : new URL(), 'URL to redirect the user back to your app from the recovery email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['clients', 'devKey']) + ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the recovery email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['platforms', 'devKey']) ->inject('request') ->inject('response') ->inject('user') @@ -3413,7 +3412,7 @@ App::post('/v1/account/verification') )) ->label('abuse-limit', 10) ->label('abuse-key', 'url:{url},userId:{userId}') - ->param('url', '', fn ($clients, $devKey) => $devKey->isEmpty() ? new Host($clients) : new URL(), 'URL to redirect the user back to your app from the verification email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['clients', 'devKey']) // TODO add built-in confirm page + ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the verification email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['platforms', 'devKey']) // TODO add built-in confirm page ->inject('request') ->inject('response') ->inject('project') diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index d6cc1c0e14..e583503237 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -8,8 +8,8 @@ use Appwrite\Event\Mail; use Appwrite\Event\Validator\Event; use Appwrite\Extend\Exception; use Appwrite\Hooks\Hooks; +use Appwrite\Network\Platform; use Appwrite\Network\Validator\Email; -use Appwrite\Network\Validator\Origin; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; @@ -1790,7 +1790,7 @@ App::post('/v1/projects/:projectId/platforms') ] )) ->param('projectId', '', new UID(), 'Project unique ID.') - ->param('type', null, new WhiteList([Origin::CLIENT_TYPE_WEB, Origin::CLIENT_TYPE_FLUTTER_WEB, Origin::CLIENT_TYPE_FLUTTER_IOS, Origin::CLIENT_TYPE_FLUTTER_ANDROID, Origin::CLIENT_TYPE_FLUTTER_LINUX, Origin::CLIENT_TYPE_FLUTTER_MACOS, Origin::CLIENT_TYPE_FLUTTER_WINDOWS, Origin::CLIENT_TYPE_APPLE_IOS, Origin::CLIENT_TYPE_APPLE_MACOS, Origin::CLIENT_TYPE_APPLE_WATCHOS, Origin::CLIENT_TYPE_APPLE_TVOS, Origin::CLIENT_TYPE_ANDROID, Origin::CLIENT_TYPE_UNITY, Origin::CLIENT_TYPE_REACT_NATIVE_IOS, Origin::CLIENT_TYPE_REACT_NATIVE_ANDROID], true), 'Platform type.') + ->param('type', null, new WhiteList([Platform::TYPE_WEB, Platform::TYPE_FLUTTER_WEB, Platform::TYPE_FLUTTER_IOS, Platform::TYPE_FLUTTER_ANDROID, Platform::TYPE_FLUTTER_LINUX, Platform::TYPE_FLUTTER_MACOS, Platform::TYPE_FLUTTER_WINDOWS, Platform::TYPE_APPLE_IOS, Platform::TYPE_APPLE_MACOS, Platform::TYPE_APPLE_WATCHOS, Platform::TYPE_APPLE_TVOS, Platform::TYPE_ANDROID, Platform::TYPE_UNITY, Platform::TYPE_REACT_NATIVE_IOS, Platform::TYPE_REACT_NATIVE_ANDROID], true), 'Platform type.') ->param('name', null, new Text(128), 'Platform name. Max length: 128 chars.') ->param('key', '', new Text(256), 'Package name for Android or bundle ID for iOS or macOS. Max length: 256 chars.', true) ->param('store', '', new Text(256), 'App store or Google Play store ID. Max length: 256 chars.', true) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 1dcf20e519..98ec49ca48 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -11,6 +11,7 @@ use Appwrite\Event\Messaging; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Network\Validator\Email; +use Appwrite\Network\Validator\Redirect; use Appwrite\Platform\Workers\Deletes; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -50,7 +51,6 @@ use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; -use Utopia\Validator\Host; use Utopia\Validator\Text; use Utopia\Validator\URL; use Utopia\Validator\WhiteList; @@ -466,7 +466,7 @@ App::post('/v1/teams/:teamId/memberships') } return new ArrayList(new Key(), APP_LIMIT_ARRAY_PARAMS_SIZE); }, 'Array of strings. Use this param to set the user roles in the team. A role can be any string. Learn more about [roles and permissions](https://appwrite.io/docs/permissions). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' roles are allowed, each 32 characters long.', false, ['project']) - ->param('url', '', fn ($clients, $devKey) => $devKey->isEmpty() ? new Host($clients) : new URL(), 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients', 'devKey']) // TODO add our own built-in confirm page + ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the invitation email. This parameter is not required when an API key is supplied. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) // TODO add our own built-in confirm page ->param('name', '', new Text(128), 'Name of the new team member. Max length: 128 chars.', true) ->inject('response') ->inject('project') diff --git a/app/controllers/api/vcs.php b/app/controllers/api/vcs.php index 6afe2d6729..a8efd093b5 100644 --- a/app/controllers/api/vcs.php +++ b/app/controllers/api/vcs.php @@ -4,6 +4,7 @@ use Appwrite\Auth\OAuth2\Github as OAuth2Github; use Appwrite\Event\Build; use Appwrite\Event\Delete; use Appwrite\Extend\Exception; +use Appwrite\Network\Validator\Redirect; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; @@ -53,7 +54,6 @@ use Utopia\Detector\Detector\Runtime; use Utopia\Detector\Detector\Strategy; use Utopia\System\System; use Utopia\Validator\Boolean; -use Utopia\Validator\Host; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; use Utopia\VCS\Adapter\Git\GitHub; @@ -426,8 +426,8 @@ App::get('/v1/vcs/github/authorize') type: MethodType::WEBAUTH, hide: true, )) - ->param('success', '', fn ($clients) => new Host($clients), 'URL to redirect back to console after a successful installation attempt.', true, ['clients']) - ->param('failure', '', fn ($clients) => new Host($clients), 'URL to redirect back to console after a failed installation attempt.', true, ['clients']) + ->param('success', '', fn ($platforms) => new Redirect($platforms), 'URL to redirect back to console after a successful installation attempt.', true, ['platforms']) + ->param('failure', '', fn ($platforms) => new Redirect($platforms), 'URL to redirect back to console after a failed installation attempt.', true, ['platforms']) ->inject('request') ->inject('response') ->inject('project') diff --git a/app/controllers/general.php b/app/controllers/general.php index 16621e1124..42e26b6fbe 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -49,7 +49,6 @@ use Utopia\Logger\Log\User; use Utopia\Logger\Logger; use Utopia\Platform\Service; use Utopia\System\System; -use Utopia\Validator\Hostname; use Utopia\Validator\Text; Config::setParam('domainVerification', false); @@ -799,6 +798,7 @@ App::init() ->inject('getProjectDB') ->inject('locale') ->inject('localeCodes') + ->inject('platforms') ->inject('geodb') ->inject('queueForStatsUsage') ->inject('queueForEvents') @@ -811,7 +811,7 @@ App::init() ->inject('apiKey') ->inject('httpReferrer') ->inject('httpReferrerSafe') - ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $console, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Certificate $queueForCertificates, Func $queueForFunctions, Executor $executor, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, string $httpReferrer, string $httpReferrerSafe) { + ->action(function (App $utopia, SwooleRequest $swooleRequest, Request $request, Response $response, Log $log, Document $console, Document $project, Database $dbForPlatform, callable $getProjectDB, Locale $locale, array $localeCodes, array $platforms, Reader $geodb, StatsUsage $queueForStatsUsage, Event $queueForEvents, Certificate $queueForCertificates, Func $queueForFunctions, Executor $executor, callable $isResourceBlocked, string $previewHostname, Document $devKey, ?Key $apiKey, string $httpReferrer, string $httpReferrerSafe) { /* * Appwrite Router */ @@ -1030,11 +1030,12 @@ App::init() * Skip this check for non-web platforms which are not required to send an origin header */ $origin = $request->getOrigin($request->getReferer('')); - $originValidator = new Origin(\array_merge($project->getAttribute('platforms', []), $console->getAttribute('platforms', []))); + $originValidator = new Origin($platforms); if ( - !$originValidator->isValid($origin) - && $devKey->isEmpty() + $devKey->isEmpty() + && !empty($origin) + && !$originValidator->isValid($origin) && \in_array($request->getMethod(), [Request::METHOD_POST, Request::METHOD_PUT, Request::METHOD_PATCH, Request::METHOD_DELETE]) && $route->getLabel('origin', false) !== '*' && empty($request->getHeader('x-appwrite-key', '')) diff --git a/app/init/resources.php b/app/init/resources.php index 339106291b..bc798aaba8 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -19,6 +19,7 @@ use Appwrite\Event\StatsUsage; use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\GraphQL\Schema; +use Appwrite\Network\Platform; use Appwrite\Network\Validator\Origin; use Appwrite\Utopia\Request; use Executor\Executor; @@ -127,11 +128,11 @@ App::setResource('queueForCertificates', function (Publisher $publisher) { App::setResource('queueForMigrations', function (Publisher $publisher) { return new Migration($publisher); }, ['publisher']); -App::setResource('clients', function ($request, $console, $project) { +App::setResource('platforms', function (Request $request, Document $console, Document $project) { $console->setAttribute('platforms', [ // Always allow current host '$collection' => ID::custom('platforms'), 'name' => 'Current Host', - 'type' => Origin::CLIENT_TYPE_WEB, + 'type' => Platform::TYPE_WEB, 'hostname' => $request->getHostname(), ], Document::SET_TYPE_APPEND); @@ -144,39 +145,32 @@ App::setResource('clients', function ($request, $console, $project) { } $console->setAttribute('platforms', [ '$collection' => ID::custom('platforms'), - 'type' => Origin::CLIENT_TYPE_WEB, + 'type' => Platform::TYPE_WEB, 'name' => $hostname, 'hostname' => $hostname, ], Document::SET_TYPE_APPEND); } - /** - * Get All verified client URLs for both console and current projects - * + Filter for duplicated entries - */ - $clientsConsole = \array_map( - fn ($node) => $node['hostname'], - \array_filter( - $console->getAttribute('platforms', []), - fn ($node) => (isset($node['type']) && ($node['type'] === Origin::CLIENT_TYPE_WEB) && !empty($node['hostname'])) - ) - ); - - $clients = $clientsConsole; - $platforms = $project->getAttribute('platforms', []); - - foreach ($platforms as $node) { - if ( - isset($node['type']) && - ($node['type'] === Origin::CLIENT_TYPE_WEB || - $node['type'] === Origin::CLIENT_TYPE_FLUTTER_WEB) && - !empty($node['hostname']) - ) { - $clients[] = $node['hostname']; - } + // Add `exp` and `appwrite-callback-{projectId}` schemes + if (!$project->isEmpty() && $project->getId() !== 'console') { + $project->setAttribute('platforms', [ + '$collection' => ID::custom('platforms'), + 'type' => Platform::TYPE_SCHEME, + 'name' => 'Expo', + 'key' => 'exp', + ], Document::SET_TYPE_APPEND); + $project->setAttribute('platforms', [ + '$collection' => ID::custom('platforms'), + 'type' => Platform::TYPE_SCHEME, + 'name' => 'Appwrite Callback', + 'key' => 'appwrite-callback-' . $project->getId(), + ], Document::SET_TYPE_APPEND); } - return \array_unique($clients); + return [ + ...$console->getAttribute('platforms', []), + ...$project->getAttribute('platforms', []), + ]; }, ['request', 'console', 'project']); App::setResource('user', function ($mode, $project, $console, $request, $response, $dbForProject, $dbForPlatform) { @@ -960,7 +954,7 @@ App::setResource('httpReferrer', function (Request $request): string { return $referrer; }, ['request']); -App::setResource('httpReferrerSafe', function (Request $request, string $httpReferrer, array $clients, Database $dbForPlatform, Document $project, App $utopia): string { +App::setResource('httpReferrerSafe', function (Request $request, string $httpReferrer, array $platforms, Database $dbForPlatform, Document $project, App $utopia): string { $origin = \parse_url($request->getOrigin($httpReferrer), PHP_URL_HOST); $protocol = \parse_url($request->getOrigin($httpReferrer), PHP_URL_SCHEME); $port = \parse_url($request->getOrigin($httpReferrer), PHP_URL_PORT); @@ -973,8 +967,8 @@ App::setResource('httpReferrerSafe', function (Request $request, string $httpRef } // Safe if added as web platform - $validator = new Hostname($clients); - if ($validator->isValid($origin)) { + $originValidator = new Origin($platforms); + if ($originValidator->isValid($request->getOrigin($httpReferrer))) { return $referrer; } @@ -1002,4 +996,4 @@ App::setResource('httpReferrerSafe', function (Request $request, string $httpRef $port = \parse_url($request->getOrigin($httpReferrer), PHP_URL_PORT); $referrer = (!empty($protocol) ? $protocol : $request->getProtocol()) . '://' . $origin . (!empty($port) ? ':' . $port : ''); return $referrer; -}, ['request', 'httpReferrer', 'clients', 'dbForPlatform', 'project', 'utopia']); +}, ['request', 'httpReferrer', 'platforms', 'dbForPlatform', 'project', 'utopia']); diff --git a/app/realtime.php b/app/realtime.php index df8cd5c471..bb0d4da78c 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -532,7 +532,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, } $timelimit = $app->getResource('timelimit'); - $console = $app->getResource('console'); /** @var Document $console */ + $platforms = $app->getResource('platforms'); $user = $app->getResource('user'); /** @var Document $user */ /* @@ -557,9 +557,9 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, * Skip this check for non-web platforms which are not required to send an origin header. */ $origin = $request->getOrigin(); - $originValidator = new Origin(array_merge($project->getAttribute('platforms', []), $console->getAttribute('platforms', []))); + $originValidator = new Origin($platforms); - if (!$originValidator->isValid($origin) && $project->getId() !== 'console') { + if (!empty($origin) && !$originValidator->isValid($origin) && $project->getId() !== 'console') { throw new Exception(Exception::REALTIME_POLICY_VIOLATION, $originValidator->getDescription()); } diff --git a/src/Appwrite/Network/Platform.php b/src/Appwrite/Network/Platform.php new file mode 100644 index 0000000000..d584ec074f --- /dev/null +++ b/src/Appwrite/Network/Platform.php @@ -0,0 +1,151 @@ + Map scheme types to user-friendly platform names. + */ + private static array $names = [ + self::SCHEME_HTTP => 'Web', + self::SCHEME_HTTPS => 'Web', + self::SCHEME_IOS => 'iOS', + self::SCHEME_MACOS => 'macOS', + self::SCHEME_WATCHOS => 'watchOS', + self::SCHEME_TVOS => 'tvOS', + self::SCHEME_ANDROID => 'Android', + self::SCHEME_WINDOWS => 'Windows', + self::SCHEME_LINUX => 'Linux', + ]; + + /** + * Get user-friendly platform name from a scheme. + * + * @param string|null $scheme + * @return string Empty string if scheme is not found. + */ + public static function getNameByScheme(?string $scheme): string + { + return self::$names[$scheme] ?? ''; + } + + public static function getHostnames(array $platforms): array + { + $hostnames = []; + foreach ($platforms as $platform) { + $type = strtolower($platform['type'] ?? self::TYPE_UNKNOWN); + $hostname = strtolower($platform['hostname'] ?? ''); + $key = strtolower($platform['key'] ?? ''); + + switch ($type) { + case self::TYPE_WEB: + case self::TYPE_FLUTTER_WEB: + if (!empty($hostname)) { + $hostnames[] = $hostname; + } + break; + case self::TYPE_FLUTTER_IOS: + case self::TYPE_FLUTTER_ANDROID: + case self::TYPE_FLUTTER_MACOS: + case self::TYPE_FLUTTER_WINDOWS: + case self::TYPE_FLUTTER_LINUX: + case self::TYPE_ANDROID: + case self::TYPE_APPLE_IOS: + case self::TYPE_APPLE_MACOS: + case self::TYPE_APPLE_WATCHOS: + case self::TYPE_APPLE_TVOS: + case self::TYPE_REACT_NATIVE_IOS: + case self::TYPE_REACT_NATIVE_ANDROID: + case self::TYPE_UNITY: + if (!empty($key)) { + $hostnames[] = $key; + } + break; + default: + break; + } + } + return array_unique($hostnames); + } + + public static function getSchemes(array $platforms): array + { + $schemes = []; + foreach ($platforms as $platform) { + $type = strtolower($platform['type'] ?? self::TYPE_UNKNOWN); + $scheme = strtolower($platform['key'] ?? ''); + + switch ($type) { + case self::TYPE_SCHEME: + if (!empty($scheme) && preg_match('/^[a-z][a-z0-9+.-]*$/', $scheme)) { + $schemes[] = $scheme; + } + break; + case self::TYPE_WEB: + case self::TYPE_FLUTTER_WEB: + $schemes[] = self::SCHEME_HTTP; + $schemes[] = self::SCHEME_HTTPS; + break; + case self::TYPE_FLUTTER_IOS: + case self::TYPE_APPLE_IOS: + case self::TYPE_REACT_NATIVE_IOS: + $schemes[] = self::SCHEME_IOS; + break; + case self::TYPE_FLUTTER_ANDROID: + case self::TYPE_ANDROID: + case self::TYPE_REACT_NATIVE_ANDROID: + $schemes[] = self::SCHEME_ANDROID; + break; + case self::TYPE_FLUTTER_MACOS: + case self::TYPE_APPLE_MACOS: + $schemes[] = self::SCHEME_MACOS; + break; + case self::TYPE_FLUTTER_WINDOWS: + case self::TYPE_UNITY: + $schemes[] = self::SCHEME_WINDOWS; + break; + case self::TYPE_FLUTTER_LINUX: + $schemes[] = self::SCHEME_LINUX; + break; + case self::TYPE_APPLE_WATCHOS: + $schemes[] = self::SCHEME_WATCHOS; + break; + case self::TYPE_APPLE_TVOS: + $schemes[] = self::SCHEME_TVOS; + break; + default: + break; + } + } + return array_unique($schemes); + } +} diff --git a/src/Appwrite/Network/Validator/Origin.php b/src/Appwrite/Network/Validator/Origin.php index d41e9af2ad..c8d9ee626d 100644 --- a/src/Appwrite/Network/Validator/Origin.php +++ b/src/Appwrite/Network/Validator/Origin.php @@ -2,150 +2,77 @@ namespace Appwrite\Network\Validator; +use Appwrite\Network\Platform; use Utopia\Validator; use Utopia\Validator\Hostname; class Origin extends Validator { - public const CLIENT_TYPE_UNKNOWN = 'unknown'; - public const CLIENT_TYPE_WEB = 'web'; - public const CLIENT_TYPE_FLUTTER_IOS = 'flutter-ios'; - public const CLIENT_TYPE_FLUTTER_ANDROID = 'flutter-android'; - public const CLIENT_TYPE_FLUTTER_MACOS = 'flutter-macos'; - public const CLIENT_TYPE_FLUTTER_WINDOWS = 'flutter-windows'; - public const CLIENT_TYPE_FLUTTER_LINUX = 'flutter-linux'; - public const CLIENT_TYPE_FLUTTER_WEB = 'flutter-web'; - public const CLIENT_TYPE_APPLE_IOS = 'apple-ios'; - public const CLIENT_TYPE_APPLE_MACOS = 'apple-macos'; - public const CLIENT_TYPE_APPLE_WATCHOS = 'apple-watchos'; - public const CLIENT_TYPE_APPLE_TVOS = 'apple-tvos'; - public const CLIENT_TYPE_ANDROID = 'android'; - public const CLIENT_TYPE_UNITY = 'unity'; - public const CLIENT_TYPE_REACT_NATIVE_IOS = 'react-native-ios'; - public const CLIENT_TYPE_REACT_NATIVE_ANDROID = 'react-native-android'; - - - public const SCHEME_TYPE_HTTP = 'http'; - public const SCHEME_TYPE_HTTPS = 'https'; - public const SCHEME_TYPE_IOS = 'appwrite-ios'; - public const SCHEME_TYPE_MACOS = 'appwrite-macos'; - public const SCHEME_TYPE_WATCHOS = 'appwrite-watchos'; - public const SCHEME_TYPE_TVOS = 'appwrite-tvos'; - public const SCHEME_TYPE_ANDROID = 'appwrite-android'; - public const SCHEME_TYPE_WINDOWS = 'appwrite-windows'; - public const SCHEME_TYPE_LINUX = 'appwrite-linux'; + protected array $hostnames = []; + protected array $schemes = []; + protected ?string $scheme = null; + protected ?string $host = null; /** - * @var array + * Constructor + * + * @param array<\Utopia\Database\Document> $platforms */ - protected $platforms = [ - self::SCHEME_TYPE_HTTP => 'Web', - self::SCHEME_TYPE_HTTPS => 'Web', - self::SCHEME_TYPE_IOS => 'iOS', - self::SCHEME_TYPE_MACOS => 'macOS', - self::SCHEME_TYPE_WATCHOS => 'watchOS', - self::SCHEME_TYPE_TVOS => 'tvOS', - self::SCHEME_TYPE_ANDROID => 'Android', - self::SCHEME_TYPE_WINDOWS => 'Windows', - self::SCHEME_TYPE_LINUX => 'Linux', - ]; - - /** - * @var array - */ - protected $clients = [ - ]; - - /** - * @var string - */ - protected $client = self::CLIENT_TYPE_UNKNOWN; - - /** - * @var string - */ - protected $host = ''; - - /** - * @param string $target - */ - public function __construct($platforms) + public function __construct(array $platforms) { - foreach ($platforms as $platform) { - $type = (isset($platform['type'])) ? $platform['type'] : ''; - - switch ($type) { - case self::CLIENT_TYPE_WEB: - case self::CLIENT_TYPE_FLUTTER_WEB: - $this->clients[] = (isset($platform['hostname'])) ? $platform['hostname'] : ''; - break; - - case self::CLIENT_TYPE_FLUTTER_IOS: - case self::CLIENT_TYPE_FLUTTER_ANDROID: - case self::CLIENT_TYPE_FLUTTER_MACOS: - case self::CLIENT_TYPE_FLUTTER_WINDOWS: - case self::CLIENT_TYPE_FLUTTER_LINUX: - case self::CLIENT_TYPE_ANDROID: - case self::CLIENT_TYPE_APPLE_IOS: - case self::CLIENT_TYPE_APPLE_MACOS: - case self::CLIENT_TYPE_APPLE_WATCHOS: - case self::CLIENT_TYPE_APPLE_TVOS: - case self::CLIENT_TYPE_REACT_NATIVE_IOS: - case self::CLIENT_TYPE_REACT_NATIVE_ANDROID: - $this->clients[] = (isset($platform['key'])) ? $platform['key'] : ''; - break; - - default: - # code... - break; - } - } + $this->hostnames = Platform::getHostnames($platforms); + $this->schemes = Platform::getSchemes($platforms); } - public function getDescription(): string - { - if (!\array_key_exists($this->client, $this->platforms)) { - return 'Unsupported platform'; - } - - return 'Invalid Origin. Register your new client (' . $this->host . ') as a new ' - . $this->platforms[$this->client] . ' platform on your project console dashboard'; - } /** - * Check if Origin has been allowed - * for access to the API - * - * @param mixed $origin - * + * Check if Origin is valid. + * @param mixed $origin The Origin URI. * @return bool */ public function isValid($origin): bool { - if (!is_string($origin)) { + $this->scheme = null; + $this->host = null; + + if (!is_string($origin) || empty($origin)) { return false; } - $scheme = \parse_url($origin, PHP_URL_SCHEME); - $host = \parse_url($origin, PHP_URL_HOST); + $this->scheme = $this->parseScheme($origin); + $this->host = strtolower(parse_url($origin, PHP_URL_HOST) ?? ''); - $this->host = $host; - $this->client = $scheme; + if (in_array($this->scheme, [Platform::SCHEME_HTTP, Platform::SCHEME_HTTPS], true)) { + $validator = new Hostname($this->hostnames); + return $validator->isValid($this->host); + } - if (empty($host)) { + if (!empty($this->scheme) && in_array($this->scheme, $this->schemes, true)) { return true; } - $validator = new Hostname($this->clients); + return false; + } - return $validator->isValid($host); + /** + * Get Description + * @return string + */ + public function getDescription(): string + { + $platform = $this->scheme ? Platform::getNameByScheme($this->scheme) : null; + $host = $this->host ? '(' . $this->host . ')' : ''; + + if (empty($this->host) && empty($this->scheme)) { + return 'Invalid Origin.'; + } + + return 'Invalid Origin. Register your new client ' . $host . ' as a new ' + . $platform . ' platform on your project console dashboard'; } /** * Is array - * - * Function will return true if object is array. - * * @return bool */ public function isArray(): bool @@ -155,13 +82,35 @@ class Origin extends Validator /** * Get Type - * - * Returns validator type. - * * @return string */ public function getType(): string { return self::TYPE_STRING; } + + /** + * Parses the scheme from a URI string. + * + * @param string $uri The URI string to parse. + * @return string|null The extracted scheme string (e.g., "http", "exp", "mailto") + */ + public function parseScheme(string $uri): ?string + { + $uri = trim($uri); + if ($uri === '') { + return null; // No scheme in empty string + } + + $scheme = parse_url($uri, PHP_URL_SCHEME); + if ($scheme === false) { + if (preg_match('/^([a-z][a-z0-9+.-]*):/i', $uri, $matches)) { + return $matches[1]; + } else { + return null; + } + } else { + return $scheme; + } + } } diff --git a/src/Appwrite/Network/Validator/Redirect.php b/src/Appwrite/Network/Validator/Redirect.php new file mode 100644 index 0000000000..162f4c7eb9 --- /dev/null +++ b/src/Appwrite/Network/Validator/Redirect.php @@ -0,0 +1,25 @@ +scheme); + $host = $this->host ? '(' . $this->host . ')' : ''; + + if (empty($this->host) && empty($this->scheme)) { + return 'Invalid URI.'; + } + + return 'Invalid URI. Register your new client ' . $host . ' as a new ' + . $platform . ' platform on your project console dashboard'; + } +} diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index aea1971be7..2a27587999 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -4637,7 +4637,6 @@ class ProjectsConsoleClientTest extends Scope 'failure' => 'https://example.com' ]); $this->assertEquals(400, $response['headers']['status-code']); - $this->assertStringContainsString('Invalid `success` param: URL host must be one of: localhost, appwrite.io, *.appwrite.io', $response['body']); /** Test oauth2 with devKey and now get oauth2 is disabled */ $response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, [ @@ -4660,7 +4659,6 @@ class ProjectsConsoleClientTest extends Scope 'url' => 'https://example.com', ]); $this->assertEquals(400, $response['headers']['status-code']); - $this->assertEquals('Invalid `url` param: URL host must be one of: localhost, appwrite.io, *.appwrite.io', $response['body']['message']); /** Test hostname in Magic URL with devKey */ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/magic-url', [ diff --git a/tests/e2e/Services/Realtime/RealtimeBase.php b/tests/e2e/Services/Realtime/RealtimeBase.php index 99f31134c0..1350b305fc 100644 --- a/tests/e2e/Services/Realtime/RealtimeBase.php +++ b/tests/e2e/Services/Realtime/RealtimeBase.php @@ -16,13 +16,6 @@ trait RealtimeBase $projectId = $this->getProject()['$id']; } - $headers = array_merge( - [ - "Origin" => "appwrite.test", - ], - $headers - ); - $query = [ "project" => $projectId, "channels" => $channels, @@ -49,7 +42,7 @@ trait RealtimeBase public function testConnectionFailureMissingChannels(): void { - $client = $this->getWebsocket(); + $client = $this->getWebsocket([]); $payload = json_decode($client->receive(), true); $this->assertArrayHasKey("type", $payload); @@ -64,14 +57,7 @@ trait RealtimeBase public function testConnectionFailureUnknownProject(): void { - $client = new WebSocketClient( - "ws://appwrite-traefik/v1/realtime?project=123", - [ - "headers" => [ - "Origin" => "appwrite.test", - ], - ] - ); + $client = $this->getWebsocket(projectId: '123'); $payload = json_decode($client->receive(), true); $this->assertArrayHasKey("type", $payload); diff --git a/tests/unit/Network/Validators/OriginTest.php b/tests/unit/Network/Validators/OriginTest.php index d9bc77c19f..989c06da71 100644 --- a/tests/unit/Network/Validators/OriginTest.php +++ b/tests/unit/Network/Validators/OriginTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Network\Validators; +use Appwrite\Network\Platform; use Appwrite\Network\Validator\Origin; use PHPUnit\Framework\TestCase; use Utopia\Database\Helpers\ID; @@ -14,62 +15,84 @@ class OriginTest extends TestCase [ '$collection' => ID::custom('platforms'), 'name' => 'Production', - 'type' => Origin::CLIENT_TYPE_WEB, + 'type' => Platform::TYPE_WEB, 'hostname' => 'appwrite.io', ], [ '$collection' => ID::custom('platforms'), 'name' => 'Development', - 'type' => Origin::CLIENT_TYPE_WEB, + 'type' => Platform::TYPE_WEB, 'hostname' => 'appwrite.test', ], [ '$collection' => ID::custom('platforms'), 'name' => 'Localhost', - 'type' => Origin::CLIENT_TYPE_WEB, + 'type' => Platform::TYPE_WEB, 'hostname' => 'localhost', ], [ '$collection' => ID::custom('platforms'), 'name' => 'Flutter', - 'type' => Origin::CLIENT_TYPE_FLUTTER_WEB, + 'type' => Platform::TYPE_FLUTTER_WEB, 'hostname' => 'appwrite.flutter', ], + [ + '$collection' => ID::custom('platforms'), + 'name' => 'Expo', + 'type' => Platform::TYPE_SCHEME, + 'key' => 'exp', + ], + [ + '$collection' => ID::custom('platforms'), + 'name' => 'Appwrite Callback', + 'type' => Platform::TYPE_SCHEME, + 'key' => 'appwrite-callback-123', + ], ]); - $this->assertEquals($validator->isValid('https://localhost'), true); - $this->assertEquals($validator->isValid('http://localhost'), true); - $this->assertEquals($validator->isValid('http://localhost:80'), true); + $this->assertEquals(false, $validator->isValid('')); + $this->assertEquals(false, $validator->isValid('/')); - $this->assertEquals($validator->isValid('https://appwrite.io'), true); - $this->assertEquals($validator->isValid('http://appwrite.io'), true); - $this->assertEquals($validator->isValid('http://appwrite.io:80'), true); + $this->assertEquals(true, $validator->isValid('https://localhost')); + $this->assertEquals(true, $validator->isValid('http://localhost')); + $this->assertEquals(true, $validator->isValid('http://localhost:80')); - $this->assertEquals($validator->isValid('https://appwrite.test'), true); - $this->assertEquals($validator->isValid('http://appwrite.test'), true); - $this->assertEquals($validator->isValid('http://appwrite.test:80'), true); + $this->assertEquals(true, $validator->isValid('https://appwrite.io')); + $this->assertEquals(true, $validator->isValid('http://appwrite.io')); + $this->assertEquals(true, $validator->isValid('http://appwrite.io:80')); - $this->assertEquals($validator->isValid('https://appwrite.flutter'), true); - $this->assertEquals($validator->isValid('http://appwrite.flutter'), true); - $this->assertEquals($validator->isValid('http://appwrite.flutter:80'), true); + $this->assertEquals(true, $validator->isValid('https://appwrite.test')); + $this->assertEquals(true, $validator->isValid('http://appwrite.test')); + $this->assertEquals(true, $validator->isValid('http://appwrite.test:80')); - $this->assertEquals($validator->isValid('https://example.com'), false); - $this->assertEquals($validator->isValid('http://example.com'), false); - $this->assertEquals($validator->isValid('http://example.com:80'), false); + $this->assertEquals(true, $validator->isValid('https://appwrite.flutter')); + $this->assertEquals(true, $validator->isValid('http://appwrite.flutter')); + $this->assertEquals(true, $validator->isValid('http://appwrite.flutter:80')); - $this->assertEquals($validator->isValid('appwrite-ios://com.company.appname'), false); - $this->assertEquals($validator->getDescription(), 'Invalid Origin. Register your new client (com.company.appname) as a new iOS platform on your project console dashboard'); + $this->assertEquals(false, $validator->isValid('https://example.com')); + $this->assertEquals(false, $validator->isValid('http://example.com')); + $this->assertEquals(false, $validator->isValid('http://example.com:80')); - $this->assertEquals($validator->isValid('appwrite-android://com.company.appname'), false); - $this->assertEquals($validator->getDescription(), 'Invalid Origin. Register your new client (com.company.appname) as a new Android platform on your project console dashboard'); + $this->assertEquals(true, $validator->isValid('exp://')); + $this->assertEquals(true, $validator->isValid('exp:///')); + $this->assertEquals(true, $validator->isValid('exp://index')); - $this->assertEquals($validator->isValid('appwrite-macos://com.company.appname'), false); - $this->assertEquals($validator->getDescription(), 'Invalid Origin. Register your new client (com.company.appname) as a new macOS platform on your project console dashboard'); + $this->assertEquals(true, $validator->isValid('appwrite-callback-123://')); + $this->assertEquals(false, $validator->isValid('appwrite-callback-456://')); - $this->assertEquals($validator->isValid('appwrite-linux://com.company.appname'), false); - $this->assertEquals($validator->getDescription(), 'Invalid Origin. Register your new client (com.company.appname) as a new Linux platform on your project console dashboard'); + $this->assertEquals(false, $validator->isValid('appwrite-ios://com.company.appname')); + $this->assertEquals('Invalid Origin. Register your new client (com.company.appname) as a new iOS platform on your project console dashboard', $validator->getDescription()); - $this->assertEquals($validator->isValid('appwrite-windows://com.company.appname'), false); - $this->assertEquals($validator->getDescription(), 'Invalid Origin. Register your new client (com.company.appname) as a new Windows platform on your project console dashboard'); + $this->assertEquals(false, $validator->isValid('appwrite-android://com.company.appname')); + $this->assertEquals('Invalid Origin. Register your new client (com.company.appname) as a new Android platform on your project console dashboard', $validator->getDescription()); + + $this->assertEquals(false, $validator->isValid('appwrite-macos://com.company.appname')); + $this->assertEquals('Invalid Origin. Register your new client (com.company.appname) as a new macOS platform on your project console dashboard', $validator->getDescription()); + + $this->assertEquals(false, $validator->isValid('appwrite-linux://com.company.appname')); + $this->assertEquals('Invalid Origin. Register your new client (com.company.appname) as a new Linux platform on your project console dashboard', $validator->getDescription()); + + $this->assertEquals(false, $validator->isValid('appwrite-windows://com.company.appname')); + $this->assertEquals('Invalid Origin. Register your new client (com.company.appname) as a new Windows platform on your project console dashboard', $validator->getDescription()); } }