diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 8fc58c52d0e..7cd5e1808c6 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -441,6 +441,28 @@ 'default' => false, 'array' => false, ], + [ + '$id' => ID::custom('photoId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('photoSize'), + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 8, + 'signed' => true, + 'required' => false, + 'default' => 0, + 'array' => false, + 'filters' => [], + ], ], 'indexes' => [ [ diff --git a/app/config/events.php b/app/config/events.php index b9eb8496881..87848601f21 100644 --- a/app/config/events.php +++ b/app/config/events.php @@ -104,6 +104,9 @@ 'impersonator' => [ '$description' => 'This event triggers when a user\'s impersonator capability is updated.', ], + 'avatar' => [ + '$description' => 'This event triggers when a user\'s photo is updated.', + ], ] ], 'databases' => [ diff --git a/app/config/roles.php b/app/config/roles.php index d68c58b17d2..c1419baa554 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -28,6 +28,7 @@ 'projects.read', 'locale.read', 'avatars.read', + 'avatars.write', 'executions.read', 'executions.write', 'targets.read', @@ -101,6 +102,7 @@ 'usage.read', 'locale.read', 'avatars.read', + 'avatars.write', 'health.read', 'functions.read', 'functions.write', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 4739f91ed63..fb428d7db71 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -415,6 +415,10 @@ 'description' => 'Access to use Avatars service', 'category' => 'Other', ], + 'avatars.write' => [ + 'description' => 'Access to update and delete the user photo', + 'category' => 'Other', + ], 'health.read' => [ 'description' => 'Access to use Health service', 'category' => 'Other', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 1967024c479..17f44142d8d 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -648,6 +648,7 @@ contentType: ContentType::NONE )) ->label('abuse-limit', 100) + ->param('current', true, new Boolean(), 'Delete the current session too. Use false to sign out of every other session while staying signed in on this one.', true) ->inject('request') ->inject('response') ->inject('user') @@ -659,7 +660,13 @@ ->inject('proofForToken') ->inject('domainVerification') ->inject('cookieDomain') - ->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, DeletePublisher $publisherForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) { + ->inject('session') + ->action(function (bool $current, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, DeletePublisher $publisherForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, ?Document $callingSession) { + + // Nothing to keep (e.g. account API key), so refuse rather than delete every session. + if (!$current && $callingSession === null) { + throw new Exception(Exception::USER_SESSION_NOT_FOUND); + } $protocol = $request->getProtocol(); $sessions = $user->getAttribute('sessions', []); @@ -667,9 +674,14 @@ foreach ($sessions as $session) { /** @var Document $session */ + if (!$current && $session->getId() === $callingSession->getId()) { + continue; + } + $dbForProject->deleteDocument('sessions', $session->getId()); - if (!$domainVerification) { + // Clears the caller's fallback cookie, so only when its own session goes too. + if (!$domainVerification && $current) { $response->addHeader('X-Fallback-Cookies', \json_encode([])); } @@ -3388,7 +3400,8 @@ ->inject('user') ->inject('store') ->inject('proofForToken') - ->action(function (int $duration, Request $request, Response $response, User $user, Store $store, ProofsToken $proofForToken) { + ->inject('project') + ->action(function (int $duration, Request $request, Response $response, User $user, Store $store, ProofsToken $proofForToken, Document $project) { if (!empty($request->getHeaderLine('x-appwrite-jwt', ''))) { throw new Exception(Exception::USER_JWT_CREATION_DENIED); } @@ -3405,6 +3418,7 @@ ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic(new Document([ 'jwt' => $jwt->encode([ + 'projectId' => $project->getId(), 'userId' => $user->getId(), 'sessionId' => $sessionId, ]) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index dca3549d39a..b47ac79023a 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -290,6 +290,9 @@ } } // Admin User Authentication elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) { + // On the console project, $team is the organization the route itself acts on (see the + // team resource), which is what lets its membership roles become the bare + // owner/developer/admin roles below. $teamId = $team->getId(); $adminRoles = []; $membershipSource = !$impersonatorUser->isEmpty() ? $targetUser : $user; diff --git a/app/init/constants.php b/app/init/constants.php index 67b1efeb3ae..d992bde08ee 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -131,6 +131,7 @@ const APP_STORAGE_IMPORTS = '/storage/imports'; // Temporary storage for csv imports const APP_STORAGE_CERTIFICATES = '/storage/certificates'; const APP_STORAGE_CONFIG = '/storage/config'; +const APP_STORAGE_PHOTOS = '_photos'; // User photos folder in each project's uploads; bucket IDs can't start with an underscore, so it never collides with a bucket's folder const APP_STORAGE_READ_BUFFER = 20 * (1000 * 1000); //20MB other names `APP_STORAGE_MEMORY_LIMIT`, `APP_STORAGE_MEMORY_BUFFER`, `APP_STORAGE_READ_LIMIT`, `APP_STORAGE_BUFFER_LIMIT` const APP_SOCIAL_TWITTER = 'https://twitter.com/appwrite'; const APP_SOCIAL_TWITTER_HANDLE = 'appwrite'; @@ -470,6 +471,7 @@ const METRIC_SITES_INBOUND = 'sites.inbound'; const METRIC_SITES_OUTBOUND = 'sites.outbound'; const METRIC_AVATARS_SCREENSHOTS_GENERATED = 'avatars.screenshotsGenerated'; +const METRIC_AVATARS_STORAGE = 'avatars.storage'; const METRIC_FUNCTIONS_RUNTIME = 'functions.runtimes.{runtime}'; const METRIC_SITES_FRAMEWORK = 'sites.frameworks.{framework}'; diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index 875542ab3a9..0c2da225d4c 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -235,6 +235,17 @@ throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage()); } + // Every project shares the signing key, and a user ID can be chosen at + // signup, so a token is only good for the project that minted it. Tokens + // minted before the projectId claim existed are accepted only when bound + // to a session, whose ID the server generated and no other project holds. + $jwtProjectId = $payload['projectId'] ?? ''; + $expectedProjectId = $mode === APP_MODE_ADMIN ? $console->getId() : $project->getId(); + $bound = $jwtProjectId !== '' ? $jwtProjectId === $expectedProjectId : !empty($payload['sessionId']); + if (!$bound) { + throw new Exception(Exception::USER_JWT_INVALID, 'JWT was not issued for this project.'); + } + $jwtUserId = $payload['userId'] ?? ''; if (!empty($jwtUserId)) { if ($mode === APP_MODE_ADMIN) { diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 09acccdcc92..31a84a79884 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -526,6 +526,17 @@ throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage()); } + // Every project shares the signing key, and a user ID can be chosen at + // signup, so a token is only good for the project that minted it. Tokens + // minted before the projectId claim existed are accepted only when bound + // to a session, whose ID the server generated and no other project holds. + $jwtProjectId = $payload['projectId'] ?? ''; + $expectedProjectId = $mode === APP_MODE_ADMIN ? $console->getId() : $project->getId(); + $bound = $jwtProjectId !== '' ? $jwtProjectId === $expectedProjectId : ! empty($payload['sessionId']); + if (! $bound) { + throw new Exception(Exception::USER_JWT_INVALID, 'JWT was not issued for this project.'); + } + $jwtUserId = $payload['userId'] ?? ''; if (! empty($jwtUserId)) { if ($mode === APP_MODE_ADMIN) { @@ -1001,7 +1012,9 @@ $team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId)); return $team; - } elseif (! empty($orgHeader)) { + } elseif (\in_array('organization', $route?->getGroups() ?? [], true) && ! empty($orgHeader)) { + // Routes in the organization group act on the organization named in the header; + // every other console route names its own team. return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader)); } } diff --git a/docker-compose.yml b/docker-compose.yml index b98bcf341d0..9e945626979 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,7 @@ services: start_period: 120s networks: - appwrite + - browser labels: - traefik.enable=true - traefik.constraint-label-stack=appwrite @@ -439,6 +440,7 @@ services: image: ${_APP_IMAGE:-appwrite/appwrite}:${_APP_VERSION:-latest} networks: - appwrite + - browser depends_on: - redis - ${_APP_DB_HOST:-postgresql} @@ -1040,6 +1042,7 @@ services: image: ${_APP_IMAGE:-appwrite/appwrite}:${_APP_VERSION:-latest} networks: - appwrite + - browser volumes: - appwrite-uploads:/storage/uploads:rw depends_on: @@ -1657,7 +1660,12 @@ services: container_name: appwrite-browser image: appwrite/browser:0.3.4 networks: - - appwrite + - browser + # Public resolvers only, so the browser cannot resolve internal service + # names (e.g. redis, appwrite-mariadb) and render them via a user URL. + dns: + - 1.1.1.1 + - 8.8.8.8 appwrite-autogravity: container_name: appwrite-autogravity @@ -1937,6 +1945,8 @@ networks: name: appwrite runtimes: name: runtimes + browser: + name: browser volumes: appwrite-mariadb: null appwrite-mongodb: null diff --git a/docs/references/account/delete-sessions.md b/docs/references/account/delete-sessions.md index f7830dfbd71..6228b17e01c 100644 --- a/docs/references/account/delete-sessions.md +++ b/docs/references/account/delete-sessions.md @@ -1 +1 @@ -Delete all sessions from the user account and remove any sessions cookies from the end client. \ No newline at end of file +Delete all sessions from the user account and remove any sessions cookies from the end client. Pass `current` as false to keep the session making the request and sign out of every other session. \ No newline at end of file diff --git a/src/Appwrite/AvatarPhotos/Providers/Custom.php b/src/Appwrite/AvatarPhotos/Providers/Custom.php new file mode 100644 index 00000000000..d840d0d5700 --- /dev/null +++ b/src/Appwrite/AvatarPhotos/Providers/Custom.php @@ -0,0 +1,36 @@ +getAttribute('photoId', '') !== ''; + } + + public function get(Document $profile, int $width, int $height, string $rating): ?string + { + $path = $this->deviceForFiles->getPath(APP_STORAGE_PHOTOS . '/' . $profile->getId() . '/' . $profile->getAttribute('photoId')); + + if (!$this->deviceForFiles->exists($path)) { + return null; + } + + return (string) $this->deviceForFiles->read($path); + } +} diff --git a/src/Appwrite/Execution/Store.php b/src/Appwrite/Execution/Store.php index d88d880891b..ff9c1b9b002 100644 --- a/src/Appwrite/Execution/Store.php +++ b/src/Appwrite/Execution/Store.php @@ -82,6 +82,14 @@ class Store 'requestPath' => ['requestPath', 'String'], ]; + private const array WINDOW_METHODS = [ + Query::TYPE_LESSER, + Query::TYPE_LESSER_EQUAL, + Query::TYPE_GREATER, + Query::TYPE_GREATER_EQUAL, + Query::TYPE_BETWEEN, + ]; + private readonly RequestFactory $requestFactory; private ?string $host = null; @@ -508,13 +516,29 @@ private function latestSql(string $where): string * the route's internal resource filters before aggregation avoids scanning * and grouping every execution in a large project. * + * A $createdAt window narrows the aggregation to the executions that have + * at least one version inside it. The window cannot filter versions + * directly: an execution queued through the API and finished by the + * functions worker gets a later createdAt on its worker-written versions, + * so dropping out-of-window versions could hide the latest one and return + * a stale status or a deleted execution. Every execution whose latest + * version matches has some version that matches, so the outer filter still + * decides on the latest snapshot. + * * @param array $queries * @param array $params */ private function latestWhere(array $queries, array &$params): string { - $conditions = ['source.projectId = {projectId:String}']; + $scope = ['projectId = {projectId:String}']; + $window = []; foreach ($queries as $query) { + if ($query->getAttribute() === '$createdAt' + && \in_array($query->getMethod(), self::WINDOW_METHODS, true)) { + $window[] = $this->filterSql($query, $params); + continue; + } + if ($query->getMethod() !== Query::TYPE_EQUAL || !\in_array($query->getAttribute(), ['resourceInternalId', 'resourceType'], true)) { continue; @@ -526,10 +550,16 @@ private function latestWhere(array $queries, array &$params): string $parameters[] = $this->parameter($type, $value, $params); } if ($parameters !== []) { - $conditions[] = "source.{$column} IN (" . \implode(', ', $parameters) . ')'; + $scope[] = "{$column} IN (" . \implode(', ', $parameters) . ')'; } } + $conditions = \array_map(fn (string $condition) => "source.{$condition}", $scope); + if ($window !== []) { + $conditions[] = 'source.id IN (SELECT id FROM ' . $this->table() + . ' WHERE ' . \implode(' AND ', [...$scope, ...$window]) . ')'; + } + return \implode(' AND ', $conditions); } diff --git a/src/Appwrite/Migration/Version/V25.php b/src/Appwrite/Migration/Version/V25.php index 86ded5b328f..c2f2fb9ed96 100644 --- a/src/Appwrite/Migration/Version/V25.php +++ b/src/Appwrite/Migration/Version/V25.php @@ -281,6 +281,15 @@ private function migrateCollections(): void Console::warning("Failed to create index \"_key_passwordPwned\" from {$id}: {$th->getMessage()}"); } + // Added in 2.3.0 for custom user photos + foreach (['photoId', 'photoSize'] as $attribute) { + try { + $this->createAttributeFromCollection($this->dbForProject, $id, $attribute); + } catch (Throwable $th) { + Console::warning("Failed to create attribute \"{$attribute}\" in collection {$id}: {$th->getMessage()}"); + } + } + $this->dbForProject->purgeCachedCollection($id); break; diff --git a/src/Appwrite/Network/Validator/PublicURL.php b/src/Appwrite/Network/Validator/PublicURL.php new file mode 100644 index 00000000000..64be1ee9f41 --- /dev/null +++ b/src/Appwrite/Network/Validator/PublicURL.php @@ -0,0 +1,58 @@ +reason !== '' ? $this->reason : parent::getDescription(); + } + + public function isValid($value): bool + { + $this->reason = ''; + + if (!parent::isValid($value)) { + return false; + } + + $host = \parse_url($value, PHP_URL_HOST) ?? ''; + + if (\filter_var(\trim($host, '[]'), FILTER_VALIDATE_IP) === false) { + try { + $known = (new Domain($host))->isKnown(); + } catch (\Throwable) { + $known = false; + } + + if (!$known) { + $this->reason = "Hostname '{$host}' is not a known public domain."; + return false; + } + } + + $hostname = new PublicHostname(); + if (!$hostname->isValid($host)) { + $this->reason = $hostname->getDescription(); + return false; + } + + return true; + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php index febbac8f520..7348fc494ce 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Favicon/Get.php @@ -4,6 +4,7 @@ use Appwrite\Extend\Exception; use Appwrite\Network\Validator\PublicHostname; +use Appwrite\Network\Validator\PublicURL; use Appwrite\Platform\Modules\Avatars\Http\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -27,7 +28,6 @@ use Utopia\Psr7\Method as RequestMethod; use Utopia\Psr7\Request\Factory as RequestFactory; use Utopia\System\System; -use Utopia\Validator\URL; class Get extends Action { @@ -67,7 +67,7 @@ public function __construct() ], contentType: ContentType::IMAGE )) - ->param('url', '', new URL(self::ALLOWED_SCHEMES), 'Website URL which you want to fetch the favicon from.') + ->param('url', '', new PublicURL(), 'Website URL which you want to fetch the favicon from.') ->inject('response') ->callback($this->action(...)); } diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php index 3f980e22422..b68f36a335f 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Image/Get.php @@ -3,7 +3,7 @@ namespace Appwrite\Platform\Modules\Avatars\Http\Image; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\PublicHostname; +use Appwrite\Network\Validator\PublicURL; use Appwrite\Platform\Modules\Avatars\Http\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -13,14 +13,12 @@ use Appwrite\Utopia\Response; use Utopia\Client\Adapter\Curl\Client as CurlAdapter; use Utopia\Client\Client; -use Utopia\Domains\Domain; use Utopia\Image\Image; use Utopia\Platform\Action as UtopiaAction; use Utopia\Platform\Scope\HTTP; use Utopia\Psr7\Method as RequestMethod; use Utopia\Psr7\Request\Factory as RequestFactory; use Utopia\Validator\Range; -use Utopia\Validator\URL; class Get extends Action { @@ -57,7 +55,7 @@ public function __construct() ], contentType: ContentType::IMAGE )) - ->param('url', '', new URL(['http', 'https']), 'Image URL which you want to crop.') + ->param('url', '', new PublicURL(), 'Image URL which you want to crop.') ->param('width', 400, new Range(0, 2000), 'Resize preview image width, Pass an integer between 0 to 2000. Defaults to 400.', true) ->param('height', 400, new Range(0, 2000), 'Resize preview image height, Pass an integer between 0 to 2000. Defaults to 400.', true) ->inject('response') @@ -74,21 +72,6 @@ public function action(string $url, int $width, int $height, Response $response) throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); } - $host = \parse_url($url, PHP_URL_HOST) ?? ''; - - $isIpLiteral = \filter_var(\trim($host, '[]'), FILTER_VALIDATE_IP) !== false; - if (!$isIpLiteral) { - $domain = new Domain($host); - if (!$domain->isKnown()) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); - } - } - - $hostnameValidator = new PublicHostname(); - if (!$hostnameValidator->isValid($host)) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED, $hostnameValidator->getDescription()); - } - try { $res = (new Client(new CurlAdapter())) ->withTimeout(15) diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Photo/Delete.php b/src/Appwrite/Platform/Modules/Avatars/Http/Photo/Delete.php new file mode 100644 index 00000000000..508f6025eac --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Photo/Delete.php @@ -0,0 +1,127 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/avatars/photo') + ->desc('Delete photo') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.write') + ->label('event', 'users.[userId].update.avatar') + ->label('audits.event', 'user.update') + ->label('audits.resource', 'user/{user.$id}') + ->label('audits.userId', '{user.$id}') + ->label('usage.resource', 'user/{user.$id}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'avatars', + group: null, + name: 'deletePhoto', + description: <<<'EOT' + Delete the custom profile photo of the currently authenticated user. Photo resolution falls back to the usual sources: OAuth2 identity photos, Gravatar, Libravatar, initials, and the static placeholder. + EOT, + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_NOCONTENT, + model: Response::MODEL_NONE, + ), + ], + contentType: ContentType::NONE + )) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('queueForEvents') + ->inject('deviceForFiles') + ->callback($this->action(...)); + } + + public function action( + Response $response, + Database $dbForProject, + User $user, + Event $queueForEvents, + Device $deviceForFiles, + ): void { + if ($user->isEmpty()) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + $photoId = $user->getAttribute('photoId', ''); + + if ($photoId === '') { + $queueForEvents->reset(); + $response->noContent(); + + return; + } + + // The file goes before the attributes, so a failure at either step is retried by calling again + $path = $deviceForFiles->getPath(APP_STORAGE_PHOTOS . '/' . $user->getId() . '/' . $photoId); + + if ($deviceForFiles->exists($path) && !$deviceForFiles->delete($path)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove photo from storage'); + } + + // A concurrent upload may have replaced the photo since it was read, so it's only cleared while it's still this one + $current = $user; + $attempts = 0; + + while ($current->getAttribute('photoId', '') === $photoId) { + try { + $dbForProject->withRequestTimestamp( + new \DateTime($current->getUpdatedAt()), + fn () => $dbForProject->updateDocument('users', $user->getId(), new Document([ + 'photoId' => '', + 'photoSize' => 0, + ])) + ); + + break; + } catch (ConflictException) { + if (++$attempts >= self::MAX_UPDATE_ATTEMPTS) { + throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT, 'Photo was changed by another request, please try again'); + } + + $current = $dbForProject->getDocument('users', $user->getId()); + } + } + + $queueForEvents->setParam('userId', $user->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Photo/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Photo/Get.php index 463b61202c0..0b9bbceab12 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Photo/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Photo/Get.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Avatars\Http\Photo; use Appwrite\AvatarPhotos\Photo; +use Appwrite\AvatarPhotos\Providers\Custom; use Appwrite\AvatarPhotos\Providers\Fallback; use Appwrite\AvatarPhotos\Providers\Gravatar; use Appwrite\AvatarPhotos\Providers\Initials; @@ -25,6 +26,7 @@ use Utopia\Image\Image; use Utopia\Platform\Action as UtopiaAction; use Utopia\Platform\Scope\HTTP; +use Utopia\Storage\Device; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; @@ -51,7 +53,7 @@ public function __construct() group: null, name: 'getPhoto', description: <<<'EOT' - Returns the best available profile photo for a user. The endpoint tries each source in priority order and returns the first successful result: OAuth2 identity photo, Gravatar, Libravatar, Appwrite Initials, built-in static fallback. + Returns the best available profile photo for a user. The endpoint tries each source in priority order and returns the first successful result: a custom uploaded photo (see avatars.updatePhoto), OAuth2 identity photo, Gravatar, Libravatar, Appwrite Initials, built-in static fallback. Passing `userId` — `current()` for the authenticated user — resolves the photo from everything known about that user: identity photos, email, and name. An explicit `emailHash` or `name` then overrides just that value, and the user's remaining sources stay in the chain. Without `userId`, passing `emailHash` and/or `name` resolves the avatar from those values alone: the hash is looked up on Gravatar and Libravatar, the name is rendered as initials, and the session user stays out of the chain so their own photo never shadows the avatar being asked for. When nothing is passed, the photo resolves for the currently authenticated user. Emails are only ever accepted pre-hashed, so no address ends up in a URL. EOT, @@ -77,6 +79,7 @@ public function __construct() ->inject('response') ->inject('user') ->inject('dbForProject') + ->inject('deviceForFiles') ->callback($this->action(...)); } @@ -92,6 +95,7 @@ public function action( Response $response, Document $user, Database $dbForProject, + Device $deviceForFiles, ): void { $emailHash = \strtolower($emailHash); @@ -123,6 +127,7 @@ public function action( if (!$photoUser->isEmpty()) { $userEmail = $photoUser->getAttribute('email', ''); $userName = $photoUser->getAttribute('name', ''); + $userPhotoId = $photoUser->getAttribute('photoId', ''); $profile = $profile->setAttribute('$id', $photoUser->getId()); @@ -133,6 +138,10 @@ public function action( if ($userEmail !== '') { $profile = $profile->setAttribute('emailHash', \hash('sha256', \strtolower(\trim($userEmail)))); } + + if ($userPhotoId !== '') { + $profile = $profile->setAttribute('photoId', $userPhotoId); + } } if ($name !== '') { @@ -145,6 +154,10 @@ public function action( $providers = []; + if ($profile->getAttribute('photoId', '') !== '') { + $providers[] = new Custom($deviceForFiles); + } + if ($profile->getId() !== '') { $providers[] = new OAuth2($dbForProject); } diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Photo/Update.php b/src/Appwrite/Platform/Modules/Avatars/Http/Photo/Update.php new file mode 100644 index 00000000000..fa3b8cb8093 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Photo/Update.php @@ -0,0 +1,210 @@ +setHttpMethod(UtopiaAction::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/avatars/photo') + ->desc('Update photo') + ->groups(['api', 'avatars']) + ->label('scope', 'avatars.write') + ->label('event', 'users.[userId].update.avatar') + ->label('audits.event', 'user.update') + ->label('audits.resource', 'user/{user.$id}') + ->label('audits.userId', '{user.$id}') + ->label('usage.resource', 'user/{user.$id}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'avatars', + group: null, + name: 'updatePhoto', + description: <<<'EOT' + Update the profile photo of the currently authenticated user. The uploaded image takes priority over every other photo source, including OAuth2 identity photos, Gravatar, and Libravatar. Updating an already customized photo replaces it. The image must be at most 5MB and is sent in a single request. + EOT, + auth: [AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_ACCOUNT, + ), + ], + requestType: ContentType::MULTIPART, + type: MethodType::UPLOAD, + )) + ->param('file', [], new File(), 'Binary image file of at most 5MB. Allowed file types are png, jpg, jpeg, and webp.', skipValidation: true) + ->inject('request') + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('queueForEvents') + ->inject('deviceForFiles') + ->inject('deviceForLocal') + ->callback($this->action(...)); + } + + public function action( + mixed $file, + Request $request, + Response $response, + Database $dbForProject, + User $user, + Event $queueForEvents, + Device $deviceForFiles, + Device $deviceForLocal, + ): void { + if ($user->isEmpty()) { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + + // Photos fit in one chunk, so a chunked upload is refused rather than stored as a partial image + if (!empty($request->getHeaderLine('content-range'))) { + throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE, 'Photo must be sent in a single request'); + } + + $file = $request->getFiles('file'); + + // GraphQL multipart spec adds files with index keys + if (empty($file)) { + $file = $request->getFiles(0); + } + + if (empty($file)) { + throw new Exception(Exception::STORAGE_FILE_EMPTY); + } + + // Make sure we handle a single file and multiple files the same way + $fileName = (\is_array($file['name']) && isset($file['name'][0])) ? $file['name'][0] : $file['name']; + $fileTmpName = (\is_array($file['tmp_name']) && isset($file['tmp_name'][0])) ? $file['tmp_name'][0] : $file['tmp_name']; + + if (!(new Upload())->isValid($fileTmpName)) { + throw new Exception(Exception::STORAGE_INVALID_FILE); + } + + if (!(new FileExt(self::ALLOWED_EXTENSIONS))->isValid($fileName)) { + throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, 'File extension not allowed'); + } + + $size = $deviceForLocal->getFileSize($fileTmpName); + + if (!(new FileSize(APP_LIMIT_UPLOAD_CHUNK_SIZE))->isValid($size)) { + throw new Exception(Exception::STORAGE_INVALID_FILE_SIZE, 'Photo must be at most 5MB'); + } + + $mimeType = $deviceForLocal->getFileMimeType($fileTmpName); + + if (!\in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) { + throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, 'Photo must be a PNG, JPEG, or WebP image'); + } + + $userId = $user->getId(); + $photoId = ID::unique(); + $path = $deviceForFiles->getPath(APP_STORAGE_PHOTOS . '/' . $userId . '/' . $photoId); + + $deviceForFiles->upload($deviceForLocal->read($fileTmpName), $path, $mimeType); + + // A concurrent upload may have replaced the photo since it was read, so the replaced photo is re-read until the update wins + $current = $user; + $attempts = 0; + + while (true) { + $previous = $current->getAttribute('photoId', ''); + + try { + if ($current->isEmpty()) { + throw new Exception(Exception::USER_NOT_FOUND); + } + + $user = $dbForProject->withRequestTimestamp( + new \DateTime($current->getUpdatedAt()), + fn () => $dbForProject->updateDocument('users', $userId, new Document([ + 'photoId' => $photoId, + 'photoSize' => $size, + ])) + ); + + break; + } catch (ConflictException) { + if (++$attempts >= self::MAX_UPDATE_ATTEMPTS) { + $deviceForFiles->delete($path); + + throw new Exception(Exception::DOCUMENT_UPDATE_CONFLICT, 'Photo was changed by another request, please try again'); + } + + $current = $dbForProject->getDocument('users', $userId); + } catch (\Throwable $th) { + $deviceForFiles->delete($path); + + throw $th; + } + } + + // The new photo is live, so a file left behind here only waits for user deletion to remove the user's photo folder + if ($previous !== '') { + try { + $previousPath = $deviceForFiles->getPath(APP_STORAGE_PHOTOS . '/' . $userId . '/' . $previous); + + if ($deviceForFiles->exists($previousPath) && !$deviceForFiles->delete($previousPath)) { + Console::warning('Failed to remove previous photo ' . $previous); + } + } catch (\Throwable $th) { + Console::warning('Failed to remove previous photo ' . $previous . ': ' . $th->getMessage()); + } + } + + $queueForEvents->setParam('userId', $userId); + + $response->dynamic($user, Response::MODEL_ACCOUNT); + } +} diff --git a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php index 032dccbafdb..c46329a18f0 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php +++ b/src/Appwrite/Platform/Modules/Avatars/Http/Screenshots/Get.php @@ -3,7 +3,7 @@ namespace Appwrite\Platform\Modules\Avatars\Http\Screenshots; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\PublicHostname; +use Appwrite\Network\Validator\PublicURL; use Appwrite\Platform\Modules\Avatars\Http\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -15,7 +15,6 @@ use Utopia\Client\Adapter\Curl\Client as CurlAdapter; use Utopia\Client\Client; use Utopia\Config\Config; -use Utopia\Domains\Domain; use Utopia\Image\Image; use Utopia\Platform\Action as UtopiaAction; use Utopia\Platform\Enum; @@ -29,13 +28,28 @@ use Utopia\Validator\Boolean; use Utopia\Validator\Range; use Utopia\Validator\Text; -use Utopia\Validator\URL; use Utopia\Validator\WhiteList; class Get extends Action { use HTTP; + /** + * The browser resolves the host itself, so a rebound DNS answer can point + * the target origin at an internal address. These headers are what cloud + * metadata services require before answering, or would retarget the + * request to another virtual host. + */ + private const BLOCKED_HEADERS = [ + 'host', + 'metadata', + 'metadata-flavor', + 'x-google-metadata-request', + 'x-aws-ec2-metadata-token', + 'x-aws-ec2-metadata-token-ttl-seconds', + 'x-aliyun-ecs-metadata-token', + ]; + public static function getName(): string { return 'getScreenshot'; @@ -69,7 +83,7 @@ public function __construct() ], contentType: ContentType::IMAGE_PNG )) - ->param('url', '', new URL(['http', 'https']), 'Website URL which you want to capture.', example: 'https://example.com') + ->param('url', '', new PublicURL(), 'Website URL which you want to capture.', example: 'https://example.com') ->param('headers', [], new Assoc(), 'HTTP headers to send with the browser request. Defaults to empty.', true, example: '{"Authorization":"Bearer token123","X-Custom-Header":"value"}') ->param('viewportWidth', 1280, new Range(1, 1920), 'Browser viewport width. Pass an integer between 1 to 1920. Defaults to 1280.', true, example: '1920') ->param('viewportHeight', 720, new Range(1, 1080), 'Browser viewport height. Pass an integer between 1 to 1080. Defaults to 720.', true, example: '1080') @@ -100,21 +114,12 @@ public function action(string $url, array $headers, int $viewportWidth, int $vie throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Imagick extension is missing'); } - $host = \parse_url($url, PHP_URL_HOST) ?? ''; - - $isIpLiteral = \filter_var(\trim($host, '[]'), FILTER_VALIDATE_IP) !== false; - if (!$isIpLiteral) { - $domain = new Domain($host); - if (!$domain->isKnown()) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED); + foreach (\array_keys($headers) as $key) { + if (\in_array(\strtolower(\trim((string) $key)), self::BLOCKED_HEADERS, true)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, "Header '{$key}' is not allowed."); } } - $hostnameValidator = new PublicHostname(); - if (!$hostnameValidator->isValid($host)) { - throw new Exception(Exception::AVATAR_REMOTE_URL_FAILED, $hostnameValidator->getDescription()); - } - // Convert indexed array to empty array (should not happen due to Assoc validator) if (count($headers) > 0 && array_keys($headers) === range(0, count($headers) - 1)) { $headers = []; diff --git a/src/Appwrite/Platform/Modules/Avatars/Services/Http.php b/src/Appwrite/Platform/Modules/Avatars/Services/Http.php index e6348fc5ad8..ac37fe119ca 100644 --- a/src/Appwrite/Platform/Modules/Avatars/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Avatars/Services/Http.php @@ -8,7 +8,9 @@ use Appwrite\Platform\Modules\Avatars\Http\Flags\Get as GetFlag; use Appwrite\Platform\Modules\Avatars\Http\Image\Get as GetImage; use Appwrite\Platform\Modules\Avatars\Http\Initials\Get as GetInitials; +use Appwrite\Platform\Modules\Avatars\Http\Photo\Delete as DeletePhoto; use Appwrite\Platform\Modules\Avatars\Http\Photo\Get as GetPhoto; +use Appwrite\Platform\Modules\Avatars\Http\Photo\Update as UpdatePhoto; use Appwrite\Platform\Modules\Avatars\Http\QR\Get as GetQR; use Appwrite\Platform\Modules\Avatars\Http\Screenshots\Get as GetScreenshot; use Utopia\Platform\Service; @@ -28,5 +30,7 @@ public function __construct() $this->addAction(GetInitials::getName(), new GetInitials()); $this->addAction(GetScreenshot::getName(), new GetScreenshot()); $this->addAction(GetPhoto::getName(), new GetPhoto()); + $this->addAction(UpdatePhoto::getName(), new UpdatePhoto()); + $this->addAction(DeletePhoto::getName(), new DeletePhoto()); } } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 8694da45a24..5f2a7b53e41 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -178,6 +178,11 @@ public function action( throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE); } + $idValidator = new UID(); + if (!$idValidator->isValid($deploymentId)) { + throw new Exception(Exception::STORAGE_INVALID_APPWRITE_ID); + } + $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE); $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1; } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index c3bca83671b..d32d0c9f8c1 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -227,6 +227,7 @@ public function action( $jwtExpiry = $function->getAttribute('timeout', 900) + 60; // 1min extra to account for possible cold-starts $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); $jwt = $jwtObj->encode([ + 'projectId' => $project->getId(), 'userId' => $user->getId(), 'sessionId' => $current->getId(), ]); diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index c6fb9c6c724..db4f90a3b7d 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -184,6 +184,11 @@ public function action( throw new Exception(Exception::STORAGE_INVALID_CONTENT_RANGE); } + $idValidator = new UID(); + if (!$idValidator->isValid($deploymentId)) { + throw new Exception(Exception::STORAGE_INVALID_APPWRITE_ID); + } + $chunks = (int) ceil($fileSize / APP_LIMIT_UPLOAD_CHUNK_SIZE); $chunk = (int) ($start / APP_LIMIT_UPLOAD_CHUNK_SIZE) + 1; } diff --git a/src/Appwrite/Platform/Modules/Users/Http/Users/JWTs/Create.php b/src/Appwrite/Platform/Modules/Users/Http/Users/JWTs/Create.php index 438b22f1479..d9907b001db 100644 --- a/src/Appwrite/Platform/Modules/Users/Http/Users/JWTs/Create.php +++ b/src/Appwrite/Platform/Modules/Users/Http/Users/JWTs/Create.php @@ -52,10 +52,11 @@ public function __construct() ->param('duration', 900, new Range(0, 3600), 'Time in seconds before JWT expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true) ->inject('response') ->inject('dbForProject') + ->inject('project') ->callback($this->action(...)); } - public function action(string $userId, string $sessionId, int $duration, Response $response, Database $dbForProject): void + public function action(string $userId, string $sessionId, int $duration, Response $response, Database $dbForProject, Document $project): void { $user = $dbForProject->getDocument('users', $userId); @@ -85,6 +86,7 @@ public function action(string $userId, string $sessionId, int $duration, Respons $response ->setStatusCode(Response::STATUS_CODE_CREATED) ->dynamic(new Document(['jwt' => $jwt->encode([ + 'projectId' => $project->getId(), 'userId' => $user->getId(), 'sessionId' => $session->isEmpty() ? '' : $session->getId() ])]), Response::MODEL_JWT); diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 27d83f70459..1ac153444c5 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -1102,6 +1102,10 @@ private function deleteUser(callable $getProjectDB, Document $document, Document // Delete targets Targets::delete($dbForProject, Query::equal('userInternalId', [$userInternalId])); + + // Delete photos, including files a racing upload or delete left behind. + // The trailing slash keeps the prefix match from reaching a user whose ID starts the same. + getDevice(APP_STORAGE_UPLOADS . '/app-' . $project->getId())->deletePath(APP_STORAGE_PHOTOS . '/' . $userId . '/'); } /** diff --git a/src/Appwrite/Platform/Workers/Functions.php b/src/Appwrite/Platform/Workers/Functions.php index a680704055e..0fd61c85cb5 100644 --- a/src/Appwrite/Platform/Workers/Functions.php +++ b/src/Appwrite/Platform/Workers/Functions.php @@ -140,6 +140,7 @@ functionId: $functionId, $jwtExpiry = $function->getAttribute('timeout', 900) + 60; // 1min extra to account for possible cold-starts $jwtObj = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $jwtExpiry, 0); $jwt = $jwtObj->encode([ + 'projectId' => $project->getId(), 'userId' => $user->getId(), ]); } diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index 42fc3c8937b..709507065f0 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -136,6 +136,7 @@ protected function count(Document $project, Database $dbForProject, Database $db array_push($gauges, ...$this->bucketGauges($project, $dbForProject)); array_push($gauges, ...$this->databaseGauges($project, $dbForProject, $getDatabasesDB)); array_push($gauges, ...$this->deploymentGauges($project, $dbForProject)); + array_push($gauges, ...$this->photoGauges($project, $dbForProject)); return $gauges; } @@ -275,6 +276,21 @@ private function deploymentGauges(Document $project, Database $dbForProject): ar return $gauges; } + /** @return array> */ + private function photoGauges(Document $project, Database $dbForProject): array + { + try { + $storage = (int) $dbForProject->sum('users', 'photoSize'); + } catch (\Throwable $th) { + Console::warning("Failed to measure photos for {$project->getId()}: " . $th->getMessage()); + return []; + } + + return [ + ['metric' => METRIC_AVATARS_STORAGE, 'value' => $storage, 'service' => '', 'resourceType' => 'project', 'resourceId' => $project->getId()], + ]; + } + /** * Deployment gauges for one compute kind (functions or sites): the * per-resource-type project totals, then one row set per function or site. diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php index 6feebaf76ca..74a0716f21a 100644 --- a/src/Appwrite/Platform/Workers/StatsUsage.php +++ b/src/Appwrite/Platform/Workers/StatsUsage.php @@ -162,6 +162,7 @@ protected function shouldSkipMetric(string $metric): bool METRIC_COLLECTIONS, METRIC_DOCUMENTS, METRIC_DATABASES_STORAGE, + METRIC_AVATARS_STORAGE, ], true); } diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index b9af53c765f..e0ec250b8b3 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -775,6 +775,7 @@ public function parse(): array case \Utopia\Validator\Host::class: case \Utopia\Validator\URL::class: case \Appwrite\Network\Validator\Redirect::class: + case \Appwrite\Network\Validator\PublicURL::class: $node['schema']['type'] = $validator->getType(); $node['schema']['format'] = 'url'; $node['schema']['example'] = ($param['example'] ?? '') !== '' ? $param['example'] : 'https://example.com'; diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index 9750f32b7fb..f013dca6f02 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -1810,6 +1810,126 @@ public function testDeleteAccountSessionsWithJWT(): void $this->assertEquals(204, $response['headers']['status-code']); } + public function testDeleteAccountSessionsKeepCurrent(): void + { + $data = $this->createFreshAccountWithSession(); + $projectId = $this->getProject()['$id']; + [$sessionA, $sessionB, $sessionC] = $this->withExtraSessions($data, 2); + + $response = $this->client->call(Client::METHOD_DELETE, '/account/sessions', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionC['secret'], + ], [ + 'current' => false, + ]); + + $this->assertEquals(204, $response['headers']['status-code']); + $this->assertArrayNotHasKey('x-fallback-cookies', $response['headers']); + + foreach ([$sessionA, $sessionB] as $deleted) { + $response = $this->client->call(Client::METHOD_GET, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $deleted['secret'], + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + } + + $cookieC = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionC['secret'], + ]; + + $response = $this->client->call(Client::METHOD_GET, '/account', $cookieC); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/account/sessions', $cookieC); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['sessions']); + $this->assertEquals($sessionC['id'], $response['body']['sessions'][0]['$id']); + $this->assertTrue($response['body']['sessions'][0]['current']); + } + + public function testDeleteAccountSessionsKeepCurrentWithJWT(): void + { + $data = $this->createFreshAccountWithSession(); + $projectId = $this->getProject()['$id']; + [$sessionA, $sessionB, $sessionC] = $this->withExtraSessions($data, 2); + + $response = $this->client->call(Client::METHOD_POST, '/account/jwt', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $sessionC['secret'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + $jwtHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-jwt' => $response['body']['jwt'], + ]; + + $response = $this->client->call(Client::METHOD_DELETE, '/account/sessions', $jwtHeaders, [ + 'current' => false, + ]); + + $this->assertEquals(204, $response['headers']['status-code']); + + foreach ([$sessionA, $sessionB] as $deleted) { + $response = $this->client->call(Client::METHOD_GET, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $deleted['secret'], + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + } + + // The JWT is backed by session C, so it only keeps working if C survived. + $response = $this->client->call(Client::METHOD_GET, '/account', $jwtHeaders); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/account/sessions', $jwtHeaders); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body']['sessions']); + $this->assertEquals($sessionC['id'], $response['body']['sessions'][0]['$id']); + $this->assertTrue($response['body']['sessions'][0]['current']); + } + + private function withExtraSessions(array $data, int $count): array + { + $sessions = [['id' => $data['sessionId'], 'secret' => $data['session']]]; + + for ($i = 0; $i < $count; $i++) { + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $data['email'], + 'password' => $data['password'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $sessions[] = [ + 'id' => $response['body']['$id'], + 'secret' => $response['cookies']['a_session_' . $this->getProject()['$id']], + ]; + } + + return $sessions; + } + public function testCreateAccountRecovery(): void { $data = $this->setupAccountWithVerifiedEmail(); diff --git a/tests/e2e/Services/Avatars/AvatarsBase.php b/tests/e2e/Services/Avatars/AvatarsBase.php index f9bf9e92bdc..15847cef624 100644 --- a/tests/e2e/Services/Avatars/AvatarsBase.php +++ b/tests/e2e/Services/Avatars/AvatarsBase.php @@ -342,7 +342,7 @@ public function testGetFavicon(): array 'url' => 'http://unknown-address.test', ]); - $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals(400, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/avatars/favicon', [ 'x-appwrite-project' => $this->getProject()['$id'], @@ -350,7 +350,7 @@ public function testGetFavicon(): array 'url' => 'http://localhost', ]); - $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals(400, $response['headers']['status-code']); return []; } @@ -815,6 +815,20 @@ public function testGetScreenshot(): array ]); $this->assertEquals(400, $response['headers']['status-code']); + /** + * Test for FAILURE - Headers that unlock cloud metadata services + */ + foreach (['Metadata-Flavor' => 'Google', 'Metadata' => 'true', 'host' => 'metadata.google.internal'] as $name => $value) { + $response = $this->client->call(Client::METHOD_GET, '/avatars/screenshots', [ + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'url' => 'https://example.com?x=' . time() . rand(1000, 9999), + 'headers' => [$name => $value], + ]); + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals(Exception::GENERAL_ARGUMENT_INVALID, $response['body']['type']); + } + /** * Test for FAILURE - Invalid viewport parameters */ diff --git a/tests/e2e/Services/Avatars/AvatarsCustomClientTest.php b/tests/e2e/Services/Avatars/AvatarsCustomClientTest.php index f2cfca110f2..f30336eaa9f 100644 --- a/tests/e2e/Services/Avatars/AvatarsCustomClientTest.php +++ b/tests/e2e/Services/Avatars/AvatarsCustomClientTest.php @@ -394,4 +394,306 @@ private function assertOAuth2Photo(string $blob): void ); } } + + public function testUpdatePhoto(): void + { + $headers = $this->createPhotoUser(); + + /** + * Test for SUCCESS — the uploaded photo wins the provider chain + */ + $red = $this->createImage('#FF0000', 'png'); + $response = $this->uploadPhoto($headers, $red, 'photo.png'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertSamePhoto($red, $this->getPhoto($headers)); + + /** + * Test for SUCCESS — the account doesn't expose photo records + */ + $account = $this->client->call(Client::METHOD_GET, '/account', $headers); + + $this->assertEquals(200, $account['headers']['status-code']); + $this->assertArrayNotHasKey('photoId', $account['body']); + $this->assertArrayNotHasKey('photoSize', $account['body']); + + /** + * Test for SUCCESS — a replacement is served right away + */ + $blue = $this->createImage('#0000FF', 'png'); + $response = $this->uploadPhoto($headers, $blue, 'photo.png'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertSamePhoto($blue, $this->getPhoto($headers)); + + /** + * Test for SUCCESS — JPEG is served, within its lossy compression + */ + $green = $this->createImage('#00FF00', 'jpeg'); + $response = $this->uploadPhoto($headers, $green, 'photo.jpg'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertSamePhoto($green, $this->getPhoto($headers), tolerance: 8); + + /** + * Test for SUCCESS — WebP is served + */ + $yellow = $this->createImage('#FFFF00', 'webp'); + $response = $this->uploadPhoto($headers, $yellow, 'photo.webp'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertSamePhoto($yellow, $this->getPhoto($headers)); + } + + public function testUpdatePhotoInvalid(): void + { + $headers = $this->createPhotoUser(); + $png = $this->createImage('#FF0000', 'png'); + + /** + * Test for FAILURE — no file + */ + $response = $this->client->call(Client::METHOD_PUT, '/avatars/photo', \array_merge($headers, [ + 'content-type' => 'multipart/form-data', + ]), [ + 'file' => '', + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals(Exception::STORAGE_FILE_EMPTY, $response['body']['type']); + + /** + * Test for FAILURE — unsupported extension + */ + $response = $this->uploadPhoto($headers, 'not an image', 'notes.txt'); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, $response['body']['type']); + + /** + * Test for FAILURE — GIF isn't supported, by extension or by content + */ + $gif = $this->createImage('#FF0000', 'gif'); + $response = $this->uploadPhoto($headers, $gif, 'photo.gif'); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, $response['body']['type']); + + $response = $this->uploadPhoto($headers, $gif, 'photo.png'); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, $response['body']['type']); + + /** + * Test for FAILURE — an SVG renamed to .png is rejected by its content + */ + $svg = ''; + $response = $this->uploadPhoto($headers, $svg, 'photo.png'); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, $response['body']['type']); + + /** + * Test for FAILURE — image over the 5MB limit + */ + $response = $this->uploadPhoto($headers, $this->createNoiseImage(1400, 1400), 'large.png'); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals(Exception::STORAGE_INVALID_FILE_SIZE, $response['body']['type']); + + /** + * Test for FAILURE — chunked uploads aren't supported + */ + $response = $this->uploadPhoto($headers, $png, 'photo.png', [ + 'content-range' => 'bytes 0-' . (\strlen($png) - 1) . '/' . \strlen($png), + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals(Exception::STORAGE_INVALID_CONTENT_RANGE, $response['body']['type']); + + /** + * Test for SUCCESS — none of the failures became the photo + */ + $this->assertPhotoInitials($this->getPhoto($headers)); + } + + public function testUpdatePhotoLarge(): void + { + $headers = $this->createPhotoUser(); + + /** + * Test for SUCCESS — an image just under the limit is served in full + */ + $large = $this->createNoiseImage(1200, 1200); + + $this->assertLessThan(5 * 1024 * 1024, \strlen($large)); + + $response = $this->uploadPhoto($headers, $large, 'large.png'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertSamePhoto($large, $this->getPhoto($headers)); + } + + public function testDeletePhoto(): void + { + $headers = $this->createPhotoUser(); + $red = $this->createImage('#FF0000', 'png'); + + $response = $this->uploadPhoto($headers, $red, 'photo.png'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertSamePhoto($red, $this->getPhoto($headers)); + + /** + * Test for SUCCESS — deleting falls back to the default chain + */ + $response = $this->client->call(Client::METHOD_DELETE, '/avatars/photo', $headers); + + $this->assertEquals(204, $response['headers']['status-code']); + $this->assertPhotoInitials($this->getPhoto($headers)); + + /** + * Test for SUCCESS — deleting again is a no-op + */ + $response = $this->client->call(Client::METHOD_DELETE, '/avatars/photo', $headers); + + $this->assertEquals(204, $response['headers']['status-code']); + $this->assertPhotoInitials($this->getPhoto($headers)); + + /** + * Test for SUCCESS — a photo can be set again after deletion + */ + $response = $this->uploadPhoto($headers, $red, 'photo.png'); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertSamePhoto($red, $this->getPhoto($headers)); + } + + /** + * A user of its own, so a photo never leaks into tests that expect the default chain. + * + * @return array + */ + private function createPhotoUser(): array + { + $projectId = $this->getProject()['$id']; + $email = \uniqid('photo-', true) . '@localhost.test'; + + $user = $this->client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => 'password', + 'name' => 'User Name', + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => 'password', + ]); + + $this->assertEquals(201, $session['headers']['status-code']); + + return [ + 'origin' => 'http://localhost', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session['cookies']['a_session_' . $projectId], + ]; + } + + /** + * Random pixels don't compress, so the PNG size follows the dimensions. + */ + private function createNoiseImage(int $width, int $height): string + { + $image = new \Imagick(); + $image->newImage($width, $height, '#808080'); + $image->addNoiseImage(\Imagick::NOISE_RANDOM); + $image->setImageDepth(8); + $image->setImageFormat('png24'); + + return $image->getImageBlob(); + } + + private function createImage(string $color, string $format): string + { + $image = new \Imagick(); + $image->newImage(64, 64, $color); + $image->setImageFormat($format); + $image->setImageCompressionQuality(100); + + if ($format === 'webp') { + $image->setOption('webp:lossless', 'true'); + } + + return $image->getImageBlob(); + } + + /** + * @param array $headers + * @param array $extra + * @return array + */ + private function uploadPhoto(array $headers, string $contents, string $filename, array $extra = []): array + { + return $this->client->call(Client::METHOD_PUT, '/avatars/photo', \array_merge($headers, [ + 'content-type' => 'multipart/form-data', + ], $extra), [ + 'file' => new \CURLFile('data://application/octet-stream;base64,' . \base64_encode($contents), 'application/octet-stream', $filename), + ]); + } + + /** + * @param array $headers + */ + private function getPhoto(array $headers): string + { + $response = $this->client->call(Client::METHOD_GET, '/avatars/photo', $headers, [ + 'width' => 0, + 'height' => 0, + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + return $response['body']; + } + + /** + * Tolerance is the largest difference allowed per colour channel, for lossy formats. + */ + private function assertSamePhoto(string $expected, string $actual, int $tolerance = 0): void + { + $expectedImage = new \Imagick(); + $expectedImage->readImageBlob($expected); + $actualImage = new \Imagick(); + $actualImage->readImageBlob($actual); + + $width = $expectedImage->getImageWidth(); + $height = $expectedImage->getImageHeight(); + + $this->assertSame([$width, $height], [$actualImage->getImageWidth(), $actualImage->getImageHeight()]); + + foreach ([[0, 0], [$width - 1, $height - 1], [\intdiv($width, 2), \intdiv($height, 2)], [\intdiv($width, 3), \intdiv($height, 5)]] as [$x, $y]) { + $expectedColor = $expectedImage->getImagePixelColor($x, $y)->getColor(); + $actualColor = $actualImage->getImagePixelColor($x, $y)->getColor(); + + foreach (['r', 'g', 'b'] as $channel) { + $this->assertLessThanOrEqual( + $tolerance, + \abs($expectedColor[$channel] - $actualColor[$channel]), + "Pixel at {$x},{$y} differs from the uploaded photo." + ); + } + } + } } diff --git a/tests/e2e/Services/Avatars/AvatarsCustomServerTest.php b/tests/e2e/Services/Avatars/AvatarsCustomServerTest.php index a6b6711fd25..4e503e3007a 100644 --- a/tests/e2e/Services/Avatars/AvatarsCustomServerTest.php +++ b/tests/e2e/Services/Avatars/AvatarsCustomServerTest.php @@ -155,4 +155,28 @@ public function testGetPhotoByUserId(): void $this->assertEquals(400, $response['headers']['status-code']); } + + public function testUpdatePhotoUnauthorized(): void + { + /** + * Test for FAILURE — API keys carry no user identity, so they can + * never upload or delete a photo + */ + $response = $this->client->call(Client::METHOD_PUT, '/avatars/photo', [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'file' => new \CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_DELETE, '/avatars/photo', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + } } diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 24b2b765832..7f2e48ab9c2 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -4,6 +4,7 @@ namespace Tests\E2E\Services\Functions; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Specification; use Appwrite\Tests\Async\Exceptions\Critical; use Appwrite\Tests\Retry; @@ -686,6 +687,49 @@ public function testCreateDeploymentWithSingleContentRangeChunk(): void $this->cleanupFunction($functionId); } + public function testCreateDeploymentRejectsPathTraversalId(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test Traversal Deployment Id', + 'execute' => [Role::user($this->getUser()['$id'])->toString()], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + + $code = $this->packageFunction('basic'); + $size = \filesize($code->getFilename()); + + // A `..` deployment id escapes the per-project storage root (CWE-22). + // The chunked-upload branch reads x-appwrite-id as the on-disk name, so + // it must be UID-validated exactly like Storage file uploads are. + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size, + 'x-appwrite-id' => '../../../tmp/appwrite-poc', + ], $this->getHeaders()), [ + 'code' => $code, + 'activate' => true, + ]); + + $this->assertEquals(400, $deployment['headers']['status-code']); + $this->assertEquals(Exception::STORAGE_INVALID_APPWRITE_ID, $deployment['body']['type']); + + // The rejection must happen before anything is written: no poisoned + // deployment row is persisted for the traversal id. + $deployments = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(200, $deployments['headers']['status-code']); + $this->assertEquals(0, $deployments['body']['total']); + + $this->cleanupFunction($functionId); + } + public function testCreateFunctionAndDeploymentFromTemplate() { @@ -2166,6 +2210,99 @@ public function testCreateExecution(): void } } + public function testListExecutionsWithinCreatedAtWindow(): void + { + $functionId = $this->setupFunction([ + 'functionId' => ID::unique(), + 'name' => 'Test executions createdAt window', + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 15, + ]); + try { + $this->setupDeployment($functionId, [ + 'code' => $this->packageFunction('basic'), + 'activate' => true, + ]); + + $sync = $this->createExecution($functionId, ['async' => 'false']); + $this->assertEquals(201, $sync['headers']['status-code']); + $syncId = $sync['body']['$id']; + + // The API stores the queued version and the worker the finished + // one, so the two versions of an async execution can carry + // different createdAt values. + $async = $this->createExecution($functionId, ['async' => true]); + $this->assertEquals(202, $async['headers']['status-code']); + $asyncId = $async['body']['$id']; + // The 202 response carries $createdAt in the database format + // (2026-09-29 12:46:25.848) while reads return ISO 8601, so bring + // every timestamp to one format before comparing them as strings. + $iso = fn (string $value) => (new \DateTimeImmutable($value, new \DateTimeZone('UTC'))) + ->setTimezone(new \DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s.vP'); + $queuedAt = $iso($async['body']['$createdAt']); + + // Both executions reach the store through the executions queue, + // so wait until each is stored in its final state. + $createdAt = []; + $this->assertEventually(function () use ($functionId, $syncId, $asyncId, $iso, &$createdAt) { + foreach ([$syncId, $asyncId] as $executionId) { + $execution = $this->getExecution($functionId, $executionId); + $this->assertEquals(200, $execution['headers']['status-code']); + $this->assertEquals('completed', $execution['body']['status']); + $createdAt[$executionId] = $iso($execution['body']['$createdAt']); + } + }, 60000, 500); + $syncCreatedAt = $createdAt[$syncId]; + $finishedAt = $createdAt[$asyncId]; + + $list = fn (array $window) => $this->listExecutions($functionId, [ + 'queries' => [...$window, Query::orderDesc('$createdAt')->toString()], + ]); + $byId = function (array $response): array { + $this->assertEquals(200, $response['headers']['status-code']); + return \array_column($response['body']['executions'], null, '$id'); + }; + + /** + * Test for SUCCESS + */ + $both = $list([ + Query::greaterThanEqual('$createdAt', \min($syncCreatedAt, $queuedAt))->toString(), + Query::lessThanEqual('$createdAt', \max($syncCreatedAt, $finishedAt))->toString(), + ]); + $executions = $byId($both); + $this->assertEquals(2, $both['body']['total']); + $this->assertEquals('completed', $executions[$syncId]['status']); + $this->assertEquals('completed', $executions[$asyncId]['status']); + + $syncOnly = $byId($list([Query::between('$createdAt', $syncCreatedAt, $syncCreatedAt)->toString()])); + $this->assertEquals('completed', $syncOnly[$syncId]['status']); + + // A window around the queued version alone must answer from the + // latest version: the finished execution when its createdAt is + // still inside the window, nothing when it has moved out, and + // never the stale queued version. + $queued = $byId($list([Query::between('$createdAt', $queuedAt, $queuedAt)->toString()])); + if ($finishedAt === $queuedAt) { + $this->assertEquals('completed', $queued[$asyncId]['status']); + } else { + $this->assertArrayNotHasKey($asyncId, $queued); + } + + $finished = $byId($list([Query::between('$createdAt', $finishedAt, $finishedAt)->toString()])); + $this->assertEquals('completed', $finished[$asyncId]['status']); + + $before = $list([Query::lessThan('$createdAt', '2000-01-01T00:00:00.000+00:00')->toString()]); + $this->assertEquals(200, $before['headers']['status-code']); + $this->assertEquals(0, $before['body']['total']); + $this->assertEmpty($before['body']['executions']); + } finally { + $this->cleanupFunction($functionId); + } + } + public function testSyncCreateExecution(): void { $data = $this->setupTestDeployment(); diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php index 9f8249db8d7..46859427728 100644 --- a/tests/e2e/Services/Sites/SitesCustomServerTest.php +++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php @@ -5,6 +5,7 @@ namespace Tests\E2E\Services\Sites; use Ahc\Jwt\JWT; +use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Specification; use Appwrite\Tests\Retry; use Tests\E2E\Client; @@ -3845,4 +3846,47 @@ public function testCreateVcsDeploymentWithoutInstallation(): void $this->cleanupSite($siteId); } + + public function testCreateDeploymentRejectsPathTraversalId(): void + { + $siteId = $this->setupSite([ + 'siteId' => ID::unique(), + 'name' => 'Test Traversal Deployment Id', + 'framework' => 'other', + 'buildRuntime' => 'node-22', + 'outputDirectory' => './', + 'fallbackFile' => '', + ]); + + $code = $this->packageSite('static'); + $size = \filesize($code->getFilename()); + + // A `..` deployment id escapes the per-project storage root (CWE-22). + // The chunked-upload branch reads x-appwrite-id as the on-disk name, so + // it must be UID-validated exactly like Storage file uploads are. + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'content-range' => 'bytes 0-' . ($size - 1) . '/' . $size, + 'x-appwrite-id' => '../../../tmp/appwrite-poc', + ], $this->getHeaders()), [ + 'code' => $code, + 'activate' => 'true', + ]); + + $this->assertEquals(400, $deployment['headers']['status-code']); + $this->assertEquals(Exception::STORAGE_INVALID_APPWRITE_ID, $deployment['body']['type']); + + // The rejection must happen before anything is written: no poisoned + // deployment row is persisted for the traversal id. + $deployments = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), []); + + $this->assertEquals(200, $deployments['headers']['status-code']); + $this->assertEquals(0, $deployments['body']['total']); + + $this->cleanupSite($siteId); + } } diff --git a/tests/e2e/Services/Teams/TeamsConsoleClientTest.php b/tests/e2e/Services/Teams/TeamsConsoleClientTest.php index 30a64247527..df9924f19ff 100644 --- a/tests/e2e/Services/Teams/TeamsConsoleClientTest.php +++ b/tests/e2e/Services/Teams/TeamsConsoleClientTest.php @@ -397,4 +397,197 @@ public function testDeleteTeamMembership(): void $this->assertEquals(200, $response['headers']['status-code']); } + + public function testTeamRoutesIgnoreOrganizationHeader(): void + { + $teamData = $this->createTeamHelper(); + $membershipData = $this->createAndAcceptMembershipHelper($teamData['teamUid'], $teamData['teamName']); + + $teamUid = $teamData['teamUid']; + $membershipUid = $membershipData['membershipUid']; + $projectId = $this->getProject()['$id']; + + // A developer sending the organization header on team routes + $headers = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-organization' => $teamUid, + 'cookie' => 'a_session_' . $projectId . '=' . $membershipData['session'], + ]; + + /** + * Test for FAILURE: owner-only actions stay owner-only with the header present + */ + $response = $this->client->call(Client::METHOD_PATCH, '/teams/' . $teamUid . '/memberships/' . $membershipUid, $headers, [ + 'roles' => ['owner'], + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals('User is not allowed to modify roles', $response['body']['message']); + + $response = $this->client->call(Client::METHOD_POST, '/teams/' . $teamUid . '/memberships', $headers, [ + 'userId' => $this->getUser()['$id'], + 'roles' => ['owner'], + 'url' => 'http://localhost:5000/join-us#title', + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals('User is not allowed to send invitations for this team', $response['body']['message']); + + $response = $this->client->call(Client::METHOD_PUT, '/teams/' . $teamUid, $headers, [ + 'name' => 'Renamed team', + ]); + + $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals('user_unauthorized', $response['body']['type']); + + $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamUid, $headers); + + $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals('user_unauthorized', $response['body']['type']); + + $response = $this->client->call(Client::METHOD_GET, '/users', $headers); + + $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals('general_unauthorized_scope', $response['body']['type']); + + /** + * Test for SUCCESS: the organization and the developer's roles are untouched + */ + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid, $headers); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($teamData['teamName'], $response['body']['name']); + + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships/' . $membershipUid, $headers); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(['developer'], $response['body']['roles']); + } + + public function testTeamRoutesIgnoreOrganizationHeaderForOtherTeams(): void + { + $teamData = $this->createTeamHelper('Target organization'); + $teamUid = $teamData['teamUid']; + $projectId = $this->getProject()['$id']; + + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(1, $response['body']['total']); + + $ownerMembershipUid = $response['body']['memberships'][0]['$id']; + + // A member of another organization + $email = uniqid() . 'member@localhost.test'; + $password = 'password'; + + $member = $this->client->call(Client::METHOD_POST, '/account', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'userId' => ID::unique(), + 'email' => $email, + 'password' => $password, + 'name' => 'Other Member', + ]); + + $this->assertEquals(201, $member['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertEquals(201, $session['headers']['status-code']); + + $memberHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session['cookies']['a_session_' . $projectId], + ]; + + $organization = $this->createTeamFixture($memberHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'Other organization', + ]); + + $headers = array_merge($memberHeaders, [ + 'x-appwrite-organization' => $organization['body']['$id'], + ]); + + /** + * Test for FAILURE: the header does not reach a team the caller is not part of + */ + $response = $this->client->call(Client::METHOD_POST, '/teams/' . $teamUid . '/memberships', $headers, [ + 'userId' => $member['body']['$id'], + 'roles' => ['owner'], + 'url' => 'http://localhost:5000/join-us#title', + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals('team_not_found', $response['body']['type']); + + $response = $this->client->call(Client::METHOD_PATCH, '/teams/' . $teamUid . '/memberships/' . $ownerMembershipUid, $headers, [ + 'roles' => ['developer'], + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals('team_not_found', $response['body']['type']); + + $response = $this->client->call(Client::METHOD_PUT, '/teams/' . $teamUid, $headers, [ + 'name' => 'Renamed team', + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals('team_not_found', $response['body']['type']); + + $response = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamUid, $headers); + + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals('team_not_found', $response['body']['type']); + + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid, $headers); + + $this->assertEquals(404, $response['headers']['status-code']); + $this->assertEquals('team_not_found', $response['body']['type']); + + $response = $this->client->call(Client::METHOD_GET, '/teams', $headers); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals([$organization['body']['$id']], array_column($response['body']['teams'], '$id')); + + $response = $this->client->call(Client::METHOD_GET, '/users', $headers); + + $this->assertEquals(401, $response['headers']['status-code']); + $this->assertEquals('general_unauthorized_scope', $response['body']['type']); + + /** + * Test for SUCCESS: the target organization is untouched + */ + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Target organization', $response['body']['name']); + $this->assertEquals(1, $response['body']['total']); + + $response = $this->client->call(Client::METHOD_GET, '/teams/' . $teamUid . '/memberships/' . $ownerMembershipUid, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertContains('owner', $response['body']['roles']); + } } diff --git a/tests/e2e/Services/Users/UsersCustomServerTest.php b/tests/e2e/Services/Users/UsersCustomServerTest.php index 0762bbef5c9..c78feb737fe 100644 --- a/tests/e2e/Services/Users/UsersCustomServerTest.php +++ b/tests/e2e/Services/Users/UsersCustomServerTest.php @@ -4,13 +4,77 @@ namespace Tests\E2E\Services\Users; +use Tests\E2E\Client; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\SideServer; +use Utopia\Database\Helpers\ID; final class UsersCustomServerTest extends Scope { use UsersBase; use ProjectCustom; use SideServer; + + public function testUserJWTIsBoundToItsProject(): void + { + $victimProject = $this->getProject(); + $attackerProject = $this->getProject(true); + + $victimId = ID::unique(); + $victim = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $victimProject['$id'], + 'x-appwrite-key' => $victimProject['apiKey'], + ], [ + 'userId' => $victimId, + 'email' => 'victim-' . $victimId . '@appwrite.io', + 'password' => 'password', + ]); + $this->assertSame(201, $victim['headers']['status-code']); + + // The attacker's own project holds users with the victim's ID and the console root's ID. + $jwts = []; + foreach ([$victimId, $this->getRoot()['$id']] as $userId) { + $user = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $attackerProject['$id'], + 'x-appwrite-key' => $attackerProject['apiKey'], + ], [ + 'userId' => $userId, + 'email' => 'attacker-' . ID::unique() . '@appwrite.io', + 'password' => 'password', + ]); + $this->assertSame(201, $user['headers']['status-code']); + + // No sessions, so the JWT carries no session to check. + $jwt = $this->client->call(Client::METHOD_POST, '/users/' . $userId . '/jwts', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $attackerProject['$id'], + 'x-appwrite-key' => $attackerProject['apiKey'], + ]); + $this->assertSame(201, $jwt['headers']['status-code']); + $jwts[$userId] = $jwt['body']['jwt']; + } + + $account = fn (string $projectId, string $jwt) => $this->client->call(Client::METHOD_GET, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-jwt' => $jwt, + ]); + + // Still good where it was minted. + $own = $account($attackerProject['$id'], $jwts[$victimId]); + $this->assertSame(200, $own['headers']['status-code']); + $this->assertSame($victimId, $own['body']['$id']); + + $crossProject = $account($victimProject['$id'], $jwts[$victimId]); + $this->assertSame(401, $crossProject['headers']['status-code']); + $this->assertArrayNotHasKey('email', $crossProject['body']); + + $console = $account('console', $jwts[$this->getRoot()['$id']]); + $this->assertSame(401, $console['headers']['status-code']); + $this->assertArrayNotHasKey('email', $console['body']); + } } diff --git a/tests/unit/Execution/StoreTest.php b/tests/unit/Execution/StoreTest.php index c158679f90f..689f78d1076 100644 --- a/tests/unit/Execution/StoreTest.php +++ b/tests/unit/Execution/StoreTest.php @@ -127,6 +127,35 @@ public function testFindsAndCountsWithExecutionQueries(): void $this->assertStringContainsString('LIMIT {param0:Int64}', $find); $this->assertStringContainsString('name="param_param0"', $find); $this->assertStringContainsString('least(count()', (string) $client->requests[1]->getBody()); + $this->assertStringNotContainsString('source.id IN (SELECT id', $find); + } + + public function testCreatedAtWindowNarrowsAggregationToMatchingExecutions(): void + { + $client = new CapturingClient([ + $this->jsonResponse([]), + $this->jsonResponse([['total' => 0]]), + ]); + $store = $this->store($client); + $queries = [ + Query::equal('resourceInternalId', ['1608']), + Query::equal('resourceType', ['functions']), + Query::greaterThanEqual('$createdAt', '2026-09-28T21:00:00.000+00:00'), + Query::lessThan('$createdAt', '2026-09-28T22:00:00.000+00:00'), + Query::orderDesc('$createdAt'), + ]; + + $store->find('project', $queries); + $store->count('project', $queries, 5000); + + foreach ($client->requests as $request) { + $body = (string) $request->getBody(); + $this->assertStringContainsString('source.id IN (SELECT id FROM `appwrite`.`executions` WHERE projectId = {projectId:String}', $body); + // The window only picks candidate executions; versions are not + // filtered by it. + $this->assertStringNotContainsString('source.createdAt >=', $body); + $this->assertStringNotContainsString('source.createdAt <', $body); + } } public function testBulkDeleteUsesLatestSnapshots(): void diff --git a/tests/unit/Network/Validators/PublicURLTest.php b/tests/unit/Network/Validators/PublicURLTest.php new file mode 100644 index 00000000000..8f5c2228a74 --- /dev/null +++ b/tests/unit/Network/Validators/PublicURLTest.php @@ -0,0 +1,54 @@ +assertTrue($validator->isValid('https://1.1.1.1/')); + $this->assertTrue($validator->isValid('http://[2606:4700:4700::1111]/path')); + } + + #[DataProvider('rejectedUrls')] + public function testRejectsNonPublicUrls(string $url, string $reason): void + { + $validator = new PublicURL(); + + $this->assertFalse($validator->isValid($url), "Expected {$url} to be rejected"); + $this->assertStringContainsString($reason, $validator->getDescription()); + } + + public static function rejectedUrls(): \Iterator + { + yield 'not a url' => ['unknown-address', 'valid URL']; + yield 'ftp scheme' => ['ftp://example.com/', 'valid URL']; + yield 'localhost' => ['http://localhost/', 'not a known public domain']; + yield 'container name' => ['http://appwrite-mariadb:3306/', 'not a known public domain']; + yield 'unknown tld' => ['http://unknown-address.test/', 'not a known public domain']; + yield 'numeric host' => ['http://2852039166/', 'not a known public domain']; + yield 'loopback' => ['http://127.0.0.1/', 'private or reserved']; + yield 'imds' => ['http://169.254.169.254/latest/meta-data/', 'private or reserved']; + yield 'private' => ['http://10.0.0.5:8080/', 'private or reserved']; + yield 'loopback v6' => ['http://[::1]/', 'private or reserved']; + yield 'ipv4-mapped' => ['http://[::ffff:127.0.0.1]/', 'private or reserved']; + } + + public function testDescriptionResetsBetweenCalls(): void + { + $validator = new PublicURL(); + + $validator->isValid('http://127.0.0.1/'); + $validator->isValid('unknown-address'); + + $this->assertStringNotContainsString('127.0.0.1', $validator->getDescription()); + } +}