Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/i18n/de/errors.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions config/i18n/en/errors.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions config/i18n/es/errors.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions config/i18n/fr/errors.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions config/i18n/it/errors.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions config/i18n/ru/errors.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => 'Требуется название двери',
Expand Down
2 changes: 2 additions & 0 deletions docs/WebDoors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 60 additions & 7 deletions src/WebDoorController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'] ?? [];
Expand Down Expand Up @@ -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
Expand All @@ -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';

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
*/
Expand Down
171 changes: 171 additions & 0 deletions tests/Unit/WebDoorGameAuthorizationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php

declare(strict_types=1);

use BinktermPHP\GameConfig;
use BinktermPHP\WebDoorController;
use PHPUnit\Framework\TestCase;

/**
* The WebDoor API (sessions, saves, leaderboards) must only read or write
* data for an installed, enabled WebDoor: the caller-supplied game_id (or
* /webdoors/{id}/ referer) is resolved against the WebDoor manifests and
* config/webdoors.json, and anything else is refused before any query.
* Uses the real installed manifests (e.g. wordle) with a controlled
* GameConfig and a recording PDO stub; no database.
*/
final class WebDoorGameAuthorizationTest extends TestCase
{
private array $savedStatics = [];
private array $savedGet = [];
private ?string $savedReferer = null;

protected function setUp(): void
{
foreach (['config', 'loaded'] as $name) {
$property = new ReflectionProperty(GameConfig::class, $name);
$property->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<string> */
public array $queries = [];
/** @var list<array> */
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];
}
}
Loading