From a6ad8b146486cc6decbff8b5b4ce1b8bde3a7d5e Mon Sep 17 00:00:00 2001 From: SkrawlCO Date: Mon, 5 Oct 2026 16:16:58 -0600 Subject: [PATCH] WebDoor API: only serve sessions, saves and scores for installed, enabled WebDoors WebDoorController used the caller-supplied game_id (or referer, default 'unknown') without checking it, so any user could create sessions, saves and leaderboard scores for arbitrary, disabled or uninstalled games. Resolve the id against the WebDoor manifests, config/webdoors.json and manifest requirements (as the listing and game page do) and return 404 errors.webdoor.game_unavailable before any query otherwise. Co-Authored-By: Claude Opus 5.5 --- config/i18n/de/errors.php | 1 + config/i18n/en/errors.php | 1 + config/i18n/es/errors.php | 1 + config/i18n/fr/errors.php | 1 + config/i18n/it/errors.php | 1 + config/i18n/ru/errors.php | 1 + docs/WebDoors.md | 2 + src/WebDoorController.php | 67 +++++++- tests/Unit/WebDoorGameAuthorizationTest.php | 171 ++++++++++++++++++++ 9 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/WebDoorGameAuthorizationTest.php diff --git a/config/i18n/de/errors.php b/config/i18n/de/errors.php index 08c55c195..3c82bf826 100644 --- a/config/i18n/de/errors.php +++ b/config/i18n/de/errors.php @@ -608,6 +608,7 @@ 'errors.webdoor.invalid_slot' => 'Ungültig: slot number', 'errors.webdoor.save_too_large' => 'data exceeds Maximum size speichern', 'errors.webdoor.save_not_found' => 'nicht gefunden speichern', + 'errors.webdoor.game_unavailable' => 'Dieses Spiel ist nicht verfügbar', // Door API 'errors.door.door_name_required' => 'Door name erforderlich', diff --git a/config/i18n/en/errors.php b/config/i18n/en/errors.php index 8aa3addc3..a50085936 100644 --- a/config/i18n/en/errors.php +++ b/config/i18n/en/errors.php @@ -613,6 +613,7 @@ 'errors.webdoor.invalid_slot' => 'Invalid slot number', 'errors.webdoor.save_too_large' => 'Save data exceeds maximum size', 'errors.webdoor.save_not_found' => 'Save not found', + 'errors.webdoor.game_unavailable' => 'This game is not available', // Door API 'errors.door.door_name_required' => 'Door name required', diff --git a/config/i18n/es/errors.php b/config/i18n/es/errors.php index 909effd7f..9cfb78b58 100644 --- a/config/i18n/es/errors.php +++ b/config/i18n/es/errors.php @@ -609,6 +609,7 @@ 'errors.webdoor.invalid_slot' => 'Numero de ranura invalido', 'errors.webdoor.save_too_large' => 'Los datos guardados exceden el tamano maximo', 'errors.webdoor.save_not_found' => 'Guardado no encontrado', + 'errors.webdoor.game_unavailable' => 'Este juego no está disponible', // Door API 'errors.door.door_name_required' => 'Se requiere el nombre de la puerta', diff --git a/config/i18n/fr/errors.php b/config/i18n/fr/errors.php index 5c4d9269b..971ff62d6 100644 --- a/config/i18n/fr/errors.php +++ b/config/i18n/fr/errors.php @@ -469,6 +469,7 @@ 'errors.webdoor.invalid_slot' => 'Numéro d\'emplacement invalide', 'errors.webdoor.save_too_large' => 'Les données de sauvegarde dépassent la taille maximale', 'errors.webdoor.save_not_found' => 'Sauvegarde introuvable', + 'errors.webdoor.game_unavailable' => 'Ce jeu n\'est pas disponible', 'errors.door.door_name_required' => 'Nom de la porte requis', 'errors.door.admin_only' => 'Cette porte est réservée aux administrateurs', 'errors.door.insufficient_credits' => 'Crédits insuffisants', diff --git a/config/i18n/it/errors.php b/config/i18n/it/errors.php index 6349a377a..4e2f4c21c 100644 --- a/config/i18n/it/errors.php +++ b/config/i18n/it/errors.php @@ -609,6 +609,7 @@ 'errors.webdoor.invalid_slot' => 'Numero slot non valido', 'errors.webdoor.save_too_large' => 'I dati di salvataggio superano la dimensione massima', 'errors.webdoor.save_not_found' => 'Salvataggio non trovato', + 'errors.webdoor.game_unavailable' => 'Questo gioco non è disponibile', // Door API 'errors.door.door_name_required' => 'Nome door obbligatorio', diff --git a/config/i18n/ru/errors.php b/config/i18n/ru/errors.php index c9a1676ea..33f5e633d 100644 --- a/config/i18n/ru/errors.php +++ b/config/i18n/ru/errors.php @@ -610,6 +610,7 @@ 'errors.webdoor.invalid_slot' => 'Недопустимый номер слота', 'errors.webdoor.save_too_large' => 'Данные сохранения превышают максимально допустимый размер', 'errors.webdoor.save_not_found' => 'Сохранение не найдено', + 'errors.webdoor.game_unavailable' => 'Эта игра недоступна', // Door API 'errors.door.door_name_required' => 'Требуется название двери', diff --git a/docs/WebDoors.md b/docs/WebDoors.md index 667dc1fe6..aedb385b6 100644 --- a/docs/WebDoors.md +++ b/docs/WebDoors.md @@ -333,6 +333,8 @@ WebDoors run within authenticated user sessions. Games can access: - User ID - Session token +The WebDoor API (`/api/webdoor/session`, `/api/webdoor/storage`, `/api/webdoor/leaderboard`) identifies the game from the `game_id` query parameter or, failing that, from a `/webdoors/{id}/` referer. The id must name an installed WebDoor (its directory or `game.id`) that is enabled in `config/webdoors.json` and whose `requirements` are met; otherwise the API answers `404` with `errors.webdoor.game_unavailable` and reads or writes nothing. + ### Storage API Games requiring persistent storage use the BBS storage API to save/load user data. diff --git a/src/WebDoorController.php b/src/WebDoorController.php index e59c6a263..43f509fed 100644 --- a/src/WebDoorController.php +++ b/src/WebDoorController.php @@ -61,7 +61,10 @@ public function getSession(): array } // Get game ID from query param or referer - $gameId = $_GET['game_id'] ?? $this->detectGameIdFromReferer() ?? 'unknown'; + $gameId = $this->resolveGameId(); + if ($gameId === null) { + return $this->errorResponse('errors.webdoor.game_unavailable', 'This game is not available', 404); + } $this->gameId = $gameId; // Check for existing valid session @@ -168,7 +171,10 @@ public function listSaves(): array return $this->errorResponse('errors.webdoor.auth_required', 'Not authenticated', 401); } - $gameId = $_GET['game_id'] ?? $this->detectGameIdFromReferer() ?? 'unknown'; + $gameId = $this->resolveGameId(); + if ($gameId === null) { + return $this->errorResponse('errors.webdoor.game_unavailable', 'This game is not available', 404); + } $stmt = $this->db->prepare(' SELECT slot, metadata, saved_at @@ -213,7 +219,10 @@ public function loadSave(int $slot): ?array return $this->errorResponse('errors.webdoor.auth_required', 'Not authenticated', 401); } - $gameId = $_GET['game_id'] ?? $this->detectGameIdFromReferer() ?? 'unknown'; + $gameId = $this->resolveGameId(); + if ($gameId === null) { + return $this->errorResponse('errors.webdoor.game_unavailable', 'This game is not available', 404); + } $stmt = $this->db->prepare(' SELECT slot, data, metadata, saved_at @@ -250,7 +259,10 @@ public function saveGame(int $slot): array return $this->errorResponse('errors.webdoor.invalid_slot', 'Invalid slot number', 400); } - $gameId = $_GET['game_id'] ?? $this->detectGameIdFromReferer() ?? 'unknown'; + $gameId = $this->resolveGameId(); + if ($gameId === null) { + return $this->errorResponse('errors.webdoor.game_unavailable', 'This game is not available', 404); + } $input = $this->getJsonInput(); $data = $input['data'] ?? []; @@ -293,7 +305,10 @@ public function deleteSave(int $slot): array return $this->errorResponse('errors.webdoor.auth_required', 'Not authenticated', 401); } - $gameId = $_GET['game_id'] ?? $this->detectGameIdFromReferer() ?? 'unknown'; + $gameId = $this->resolveGameId(); + if ($gameId === null) { + return $this->errorResponse('errors.webdoor.game_unavailable', 'This game is not available', 404); + } $stmt = $this->db->prepare(' DELETE FROM webdoor_storage @@ -315,7 +330,10 @@ public function getLeaderboard(string $board): array return $this->errorResponse('errors.webdoor.auth_required', 'Not authenticated', 401); } - $gameId = $_GET['game_id'] ?? $this->detectGameIdFromReferer() ?? 'unknown'; + $gameId = $this->resolveGameId(); + if ($gameId === null) { + return $this->errorResponse('errors.webdoor.game_unavailable', 'This game is not available', 404); + } $limit = min((int)($_GET['limit'] ?? 10), 100); $scope = $_GET['scope'] ?? 'all'; @@ -407,7 +425,10 @@ public function submitScore(string $board): array return $this->errorResponse('errors.webdoor.auth_required', 'Not authenticated', 401); } - $gameId = $_GET['game_id'] ?? $this->detectGameIdFromReferer() ?? 'unknown'; + $gameId = $this->resolveGameId(); + if ($gameId === null) { + return $this->errorResponse('errors.webdoor.game_unavailable', 'This game is not available', 404); + } $input = $this->getJsonInput(); $score = (int)($input['score'] ?? 0); @@ -456,6 +477,38 @@ public function submitScore(string $board): array ]; } + /** + * The requested WebDoor (explicit game_id, or the /webdoors/{id}/ referer), + * resolved to its canonical manifest id - or null when it is not an + * installed, enabled WebDoor whose requirements are met. Sessions, saves + * and leaderboard entries are only ever read or written for such games. + */ + private function resolveGameId(): ?string + { + $requested = $_GET['game_id'] ?? $this->detectGameIdFromReferer(); + if (!is_string($requested) || $requested === '') { + return null; + } + + foreach (WebDoorManifest::listManifests() as $entry) { + if ($entry['id'] !== $requested && $entry['path'] !== $requested) { + continue; + } + if (!isset($entry['manifest']['game']) || !GameConfig::isEnabled($entry['id'])) { + return null; + } + // Same requirement check the game page and listing use (defined in + // routes/webdoor-routes.php, which serves every WebDoor API call). + if (function_exists('checkManifestRequirements') && !checkManifestRequirements($entry['manifest'])) { + return null; + } + + return $entry['id']; + } + + return null; + } + /** * Detect game ID from HTTP referer */ diff --git a/tests/Unit/WebDoorGameAuthorizationTest.php b/tests/Unit/WebDoorGameAuthorizationTest.php new file mode 100644 index 000000000..c0d3d33c3 --- /dev/null +++ b/tests/Unit/WebDoorGameAuthorizationTest.php @@ -0,0 +1,171 @@ +setAccessible(true); + $this->savedStatics[$name] = [$property, $property->getValue()]; + } + $this->setGameConfig(['wordle' => ['enabled' => true], 'hangman' => ['enabled' => false]]); + $this->savedGet = $_GET; + $this->savedReferer = $_SERVER['HTTP_REFERER'] ?? null; + $_GET = []; + unset($_SERVER['HTTP_REFERER']); + } + + protected function tearDown(): void + { + foreach ($this->savedStatics as [$property, $value]) { + $property->setValue(null, $value); + } + $_GET = $this->savedGet; + if ($this->savedReferer === null) { + unset($_SERVER['HTTP_REFERER']); + } else { + $_SERVER['HTTP_REFERER'] = $this->savedReferer; + } + } + + public function testEnabledWebDoorIsServedUnderItsCanonicalId(): void + { + $_GET['game_id'] = 'wordle'; + [$controller, $pdo] = $this->controller(); + + $result = $controller->listSaves(); + + self::assertArrayHasKey('slots', $result); + self::assertSame([[7, 'wordle'], [7, 'wordle']], $pdo->params); + } + + public function testRefererIdentifiesTheGameToo(): void + { + $_SERVER['HTTP_REFERER'] = 'https://bbs.example/webdoors/wordle/index.html'; + [$controller, $pdo] = $this->controller(); + + self::assertArrayHasKey('slots', $controller->listSaves()); + self::assertSame('wordle', $pdo->params[0][1]); + } + + /** + * @dataProvider refusedGameIds + */ + public function testUnknownDisabledOrMissingGamesAreRefusedBeforeAnyQuery(?string $gameId): void + { + if ($gameId !== null) { + $_GET['game_id'] = $gameId; + } + + foreach (['listSaves', 'getSession'] as $method) { + [$controller, $pdo] = $this->controller(); + $result = $controller->$method(); + self::assertFalse($result['success'], "{$method}({$gameId})"); + self::assertSame('errors.webdoor.game_unavailable', $result['error_code']); + self::assertSame([], $pdo->queries, "{$method} must not touch the database"); + } + foreach ([fn ($c) => $c->loadSave(1), fn ($c) => $c->saveGame(1), fn ($c) => $c->deleteSave(1), + fn ($c) => $c->getLeaderboard('high'), fn ($c) => $c->submitScore('high')] as $call) { + [$controller, $pdo] = $this->controller(); + $result = $call($controller); + self::assertSame('errors.webdoor.game_unavailable', $result['error_code'] ?? null); + self::assertSame([], $pdo->queries); + } + } + + public static function refusedGameIds(): array + { + return [ + 'not installed' => ['no-such-game'], + 'disabled in webdoors.json' => ['hangman'], + 'installed but not configured' => ['blackjack'], + 'no game id at all' => [null], + 'path traversal' => ['../wordle'], + ]; + } + + private function controller(): array + { + $controller = (new ReflectionClass(WebDoorController::class))->newInstanceWithoutConstructor(); + $pdo = new WebDoorAuthorizationPdo(); + $auth = new class { + public function getCurrentUser(): array + { + return ['user_id' => 7, 'username' => 'alice']; + } + }; + foreach (['db' => $pdo, 'auth' => $auth] as $name => $value) { + $property = new ReflectionProperty(WebDoorController::class, $name); + $property->setAccessible(true); + $property->setValue($controller, $value); + } + + return [$controller, $pdo]; + } + + private function setGameConfig(array $config): void + { + $this->savedStatics['config'][0]->setValue(null, $config); + $this->savedStatics['loaded'][0]->setValue(null, true); + } +} + +final class WebDoorAuthorizationPdo extends PDO +{ + /** @var list */ + public array $queries = []; + /** @var list */ + public array $params = []; + + public function __construct() + { + } + + public function prepare(string $query, array $options = []): PDOStatement|false + { + $this->queries[] = $query; + return new WebDoorAuthorizationStatement($this); + } +} + +final class WebDoorAuthorizationStatement extends PDOStatement +{ + public function __construct(private WebDoorAuthorizationPdo $pdo) + { + } + + public function execute(?array $params = null): bool + { + $this->pdo->params[] = $params ?? []; + return true; + } + + public function fetchAll(int $mode = PDO::FETCH_DEFAULT, mixed ...$args): array + { + return []; + } + + public function fetch(int $mode = PDO::FETCH_DEFAULT, int $cursorOrientation = PDO::FETCH_ORI_NEXT, int $cursorOffset = 0): mixed + { + return ['total_bytes' => 0]; + } +}