The editor is about to be opened to strangers: a post on Reddit and a guest access (#428). nginx served the whole workspace and ran every PHP file in it, the API let anybody guess passwords as fast as they liked, and one account could fill the disk. - nginx (shared web stack, outside this repo, backup in /root/config-backup-20261015/) hands out only the two pages, dist/, public/brand/, the uploads by kind and ending, and api/index.php. Everything else under /feuerwerk/ is a 404. - PHP behind it has display_errors off and no X-Powered-By. Plain HTTP forwards to HTTPS, the API refuses it. - Headers: a Content-Security-Policy without inline code or eval, nosniff, frame-ancestors, HSTS, Referrer-, Permissions-, COOP, CORP. - vite.config.ts writes the same policy into the built pages, so CI runs the whole suite under it. Pixi gets by without eval through pixi.js/unsafe-eval. The page CSS left its inline style for a file. - The harness fails a module for anything the policy refuses, from Chrome's log and from the page's securitypolicyviolation event. - LoginThrottle: five wrong passwords from one address within 15 minutes, and it gets 429 until 15 minutes after the last of them. The address is the last X-Forwarded-For entry, the Brandmauer's own. - Quota: 1 GB per account for uploads, projects and snapshots. Only a write that grows the account is refused (413). Admins have no limit. - A write whose Sec-Fetch-Site says another site is refused (403). - The session cookie carries Secure, nginx passes HTTPS on. - showFailure puts the message in as text, not as HTML. - Every PHP script outside api/ stops unless it runs on the command line. - The video worker sees the checkout read-only and can only write data/videos. No capabilities, 2 GB and 256 processes at most. - tests/php/rights.test.php runs the API under php -S on a scratch directory and asks every route as signed out, owner, stranger and admin. A route named in api/index.php but missing in the matrix fails it. limits.test.php covers the brake and the quota. Findings: - tmp/ was readable from outside, with 79 copies of project files. Closed on 15.10. at 20:02 with chmod 750. The log back to 11.10. shows only our own requests. - Port 8080 is bound on 0.0.0.0, so the LAN could reach nginx past the Brandmauer. Left as it is: the web stack is not this project's. - npm audit finds nothing in what is shipped. Dev tools (vitest, brace-expansion, source-map-js) have advisories, not touched here. Tests: check 313 files / 4843 tests. PHP limits 21, rights 60, accounts 56, snapshots 15, releases 20, files 46 (videos 50/51, no ffmpeg in the web container). e2e on stein2, atx was off: 34+38+41 197/198, the one red check (a click in 41) passes with 41 alone, 27/27. 67 misses frames at 1080p on stein2 with and without this. Path list from outside: 10 served, 26 refused. ZAP baseline against the live site: nothing High or Medium under /feuerwerk/. Counter-checks: rights.test.php without the cross-site guard, the quota, the brake, the admin check of users, or with an undocumented route fails each time. A policy without wasm-unsafe-eval fails e2e 41 with the harness's own CSP line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
9.7 KiB
PHP
188 lines
9.7 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
// A command-line script: under a web server it does nothing (#427).
|
||
if (PHP_SAPI !== 'cli') {
|
||
exit(1);
|
||
}
|
||
|
||
/**
|
||
* Accounts, roles and who may touch which project (#180, #179), without a web server: the stores
|
||
* work on a scratch directory. Plain PHP, no framework – CI runs it with `php tests/php/accounts.test.php`
|
||
* and reads the last line, as it does for the other suites.
|
||
*/
|
||
|
||
require __DIR__ . '/../../api/lib/AccountRules.php';
|
||
require __DIR__ . '/../../api/lib/AtomicFile.php';
|
||
require __DIR__ . '/../../api/lib/Store.php';
|
||
require __DIR__ . '/../../api/lib/UserStore.php';
|
||
|
||
$failed = 0;
|
||
$passed = 0;
|
||
|
||
function check(string $name, bool $ok, mixed $got = null): void
|
||
{
|
||
global $failed, $passed;
|
||
if ($ok) {
|
||
$passed++;
|
||
echo "PASS {$name}\n";
|
||
return;
|
||
}
|
||
$failed++;
|
||
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
|
||
}
|
||
|
||
function throws(callable $work, string $class = InvalidArgumentException::class): bool
|
||
{
|
||
try {
|
||
$work();
|
||
} catch (Throwable $e) {
|
||
if ($e instanceof $class) {
|
||
return true;
|
||
}
|
||
throw $e;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
$dir = sys_get_temp_dir() . '/feuerwerk-php-test-' . bin2hex(random_bytes(4));
|
||
mkdir($dir);
|
||
$file = "{$dir}/users.json";
|
||
|
||
// --- UserStore ---------------------------------------------------------------------------------
|
||
|
||
file_put_contents($file, json_encode(['users' => [
|
||
['username' => 'old', 'password' => password_hash('secret12', PASSWORD_DEFAULT)],
|
||
['username' => 'odd', 'password' => password_hash('secret12', PASSWORD_DEFAULT), 'role' => 'root'],
|
||
]]));
|
||
$users = new UserStore($file);
|
||
check('an account without a role is a user', $users->role('old') === 'user', $users->role('old'));
|
||
check('an unknown role counts as user', $users->role('odd') === 'user', $users->role('odd'));
|
||
check('an account that is not there is a user', $users->role('nobody') === 'user');
|
||
check('the old accounts have no creation date', $users->accounts()[1]['createdAt'] === '', $users->accounts());
|
||
|
||
$users->put('boss', 'secret12', 'admin');
|
||
check('a role given on creation is kept', $users->role('boss') === 'admin');
|
||
check('a new account gets a creation date', $users->accounts()[0]['createdAt'] !== '', $users->accounts()[0]);
|
||
$created = $users->accounts()[0]['createdAt'];
|
||
$users->put('boss', 'another12');
|
||
check('a new password without a role leaves the role alone', $users->role('boss') === 'admin');
|
||
check('a new password leaves the creation date alone', $users->accounts()[0]['createdAt'] === $created);
|
||
check('the new password is the one that works', $users->verify('boss', 'another12') === 'boss');
|
||
$users->put('fresh', 'secret12');
|
||
check('a new account without a role is a user', $users->role('fresh') === 'user');
|
||
check('the role is written to the file', str_contains((string) file_get_contents($file), '"role": "admin"'));
|
||
check('an unknown role is refused', throws(fn() => $users->put('x1', 'secret12', 'root')));
|
||
check(
|
||
'the accounts carry no hash',
|
||
!str_contains(json_encode($users->accounts()), 'password') && !str_contains(json_encode($users->accounts()), '$2y$'),
|
||
);
|
||
check('one administrator counted', $users->adminCount() === 1, $users->adminCount());
|
||
$users->setRole('fresh', 'admin');
|
||
check('setRole makes an administrator', $users->role('fresh') === 'admin' && $users->adminCount() === 2);
|
||
check('setRole on nobody answers false', $users->setRole('nobody', 'user') === false);
|
||
|
||
check('rename moves the account', $users->rename('fresh', 'renamed') && $users->exists('renamed') && !$users->exists('fresh'));
|
||
check('rename keeps password and role', $users->verify('renamed', 'secret12') === 'renamed' && $users->role('renamed') === 'admin');
|
||
check('rename onto a taken name is refused', throws(fn() => $users->rename('renamed', 'OLD')));
|
||
check('the refused rename changed nothing', $users->exists('renamed') && $users->role('old') === 'user');
|
||
check('rename to an invalid name is refused', throws(fn() => $users->rename('renamed', 'a b')));
|
||
check('a change of case is the same account', $users->rename('renamed', 'Renamed') && $users->accounts()[0]['username'] === 'Renamed', $users->accounts());
|
||
check('rename of nobody answers false', $users->rename('nobody', 'somebody') === false);
|
||
check('remove takes the account away', $users->remove('renamed') && !$users->exists('renamed'));
|
||
check('remove of nobody answers false', $users->remove('renamed') === false);
|
||
|
||
// --- Store: who reaches a project --------------------------------------------------------------
|
||
|
||
$projects = "{$dir}/projects";
|
||
$mine = new Store($projects, 'anna');
|
||
$doc = $mine->create('Anna\'s game');
|
||
$id = $doc['id'];
|
||
$stranger = new Store($projects, 'bert');
|
||
$admin = new Store($projects, 'boss', true);
|
||
|
||
check('a stranger does not see the project', $stranger->load($id) === null);
|
||
check('a stranger cannot save it', $stranger->save($id, 1, ['name' => 'x']) === null);
|
||
check('a stranger cannot delete it', $stranger->delete($id) === false);
|
||
check('an administrator loads it', $admin->load($id)['name'] === "Anna's game");
|
||
$saved = $admin->save($id, 1, ['name' => 'Edited by boss']);
|
||
check('an administrator saves it', $saved !== null && $saved['revision'] === 2, $saved);
|
||
check('the save keeps the owner', $mine->load($id)['owner'] === 'anna', $mine->load($id)['owner'] ?? null);
|
||
check('the owner sees the edit', $mine->load($id)['name'] === 'Edited by boss');
|
||
check('the administrator\'s own list stays their own', $admin->list() === []);
|
||
check('listOf lists somebody else\'s projects', array_column($admin->listOf('anna'), 'id') === [$id]);
|
||
|
||
check('transfer moves the project to the new name', $admin->transfer('anna', 'annabel') === 1);
|
||
$moved = (new Store($projects, 'annabel'))->load($id);
|
||
check('the new name owns it', $moved !== null && $moved['owner'] === 'annabel');
|
||
check('the old name does not', $mine->load($id) === null);
|
||
check('transfer keeps the revision', $moved['revision'] === 2, $moved['revision'] ?? null);
|
||
|
||
$carl = new Store($projects, 'carl');
|
||
$other = $carl->create('Carl\'s game');
|
||
check('an administrator may not delete somebody else\'s project', throws(fn() => $admin->delete($other['id']), NotOwnerException::class));
|
||
check('the refused delete leaves the project', $carl->load($other['id']) !== null);
|
||
check('its owner deletes it', $carl->delete($other['id']) && $admin->listOf('carl') === []);
|
||
$ownerless = "{$projects}/nobody01.json";
|
||
file_put_contents($ownerless, json_encode(['id' => 'nobody01', 'revision' => 1, 'project' => []]));
|
||
check('an administrator may not delete a project without an owner', throws(fn() => $admin->delete('nobody01'), NotOwnerException::class) && is_file($ownerless));
|
||
$own = $admin->create('Boss\'s game');
|
||
check('an administrator deletes their own project', $admin->delete($own['id']) && $admin->list() === []);
|
||
|
||
// The web server serves `data/` as static files, so a readable project would skip the sign-in.
|
||
check('a project file is not readable for the web server', (fileperms("{$projects}/{$id}.json") & 0o007) === 0);
|
||
|
||
// --- Store: saves at the same time -------------------------------------------------------------
|
||
|
||
// Every worker saves with the revision it just read and tries again when it was beaten to it, so
|
||
// no save may be lost: each one that succeeded has to show in the revision.
|
||
if (!function_exists('pcntl_fork')) {
|
||
echo "SKIP saves at the same time (this PHP has no pcntl)\n";
|
||
} else {
|
||
$raceDir = "{$dir}/race";
|
||
$raceId = (new Store($raceDir, 'anna'))->create('Race')['id'];
|
||
$workers = 8;
|
||
$rounds = 40;
|
||
$children = [];
|
||
for ($w = 0; $w < $workers; $w++) {
|
||
$pid = pcntl_fork();
|
||
if ($pid === 0) {
|
||
$store = new Store($raceDir, 'anna');
|
||
for ($won = 0; $won < $rounds;) {
|
||
try {
|
||
$store->save($raceId, $store->load($raceId)['revision'], ['name' => 'Race', 'by' => $w]);
|
||
$won++;
|
||
} catch (StaleRevisionException) {
|
||
}
|
||
}
|
||
exit(0);
|
||
}
|
||
$children[] = $pid;
|
||
}
|
||
foreach ($children as $pid) {
|
||
pcntl_waitpid($pid, $status);
|
||
}
|
||
$final = (new Store($raceDir, 'anna'))->load($raceId)['revision'];
|
||
check("{$workers} tabs saving at once lose none of the saves", $final === 1 + $workers * $rounds, $final);
|
||
}
|
||
|
||
// --- AccountRules ------------------------------------------------------------------------------
|
||
|
||
check('renaming yourself is refused', AccountRules::renameRefusal('boss', 'BOSS') !== null);
|
||
check('renaming somebody else is fine', AccountRules::renameRefusal('boss', 'anna') === null);
|
||
check('changing your own role is refused', AccountRules::roleRefusal('boss', 'boss', 'admin', 'user', 2) !== null);
|
||
check('setting your own role to what it is is fine', AccountRules::roleRefusal('boss', 'boss', 'admin', 'admin', 1) === null);
|
||
check('the last administrator cannot be demoted', AccountRules::roleRefusal('x', 'boss', 'admin', 'user', 1) !== null);
|
||
check('an administrator among two can be demoted', AccountRules::roleRefusal('x', 'boss', 'admin', 'user', 2) === null);
|
||
check('deleting yourself is refused', AccountRules::deleteRefusal('boss', 'boss', 'admin', 2) !== null);
|
||
check('the last administrator cannot be deleted', AccountRules::deleteRefusal('x', 'boss', 'admin', 1) !== null);
|
||
check('a user can be deleted', AccountRules::deleteRefusal('boss', 'anna', 'user', 1) === null);
|
||
check('an account with projects is not deleted', AccountRules::ownerRefusal('anna', 2) === 'anna still owns 2 projects');
|
||
check('one project is said as one', AccountRules::ownerRefusal('anna', 1) === 'anna still owns 1 project');
|
||
check('an account without projects may go', AccountRules::ownerRefusal('anna', 0) === null);
|
||
|
||
exec('rm -rf ' . escapeshellarg($dir));
|
||
|
||
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
|
||
exit($failed === 0 ? 0 : 1);
|