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>
154 lines
8.6 KiB
PHP
154 lines
8.6 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);
|
||
}
|
||
|
||
/**
|
||
* Uploaded files in a folder per account (#344): the stores, the folders in `users.json` and the
|
||
* check whether a project still uses a file. Plain PHP like releases.test.php; CI runs it with
|
||
* `php tests/php/files.test.php` and reads the last line.
|
||
*/
|
||
|
||
require __DIR__ . '/../../api/lib/AtomicFile.php';
|
||
require __DIR__ . '/../../api/lib/OriginalName.php';
|
||
require __DIR__ . '/../../api/lib/UserStore.php';
|
||
require __DIR__ . '/../../api/lib/HashedFileStore.php';
|
||
require __DIR__ . '/../../api/lib/FileUsage.php';
|
||
require __DIR__ . '/../../api/lib/SoundStore.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, JSON_UNESCAPED_UNICODE), "\n";
|
||
}
|
||
|
||
/** A WAV header and some silence: enough for the store to take it, different per `$tag`. */
|
||
function wav(string $tag): string
|
||
{
|
||
return 'RIFF' . pack('V', 36) . 'WAVE' . str_repeat("\0", 32) . $tag;
|
||
}
|
||
|
||
$data = sys_get_temp_dir() . '/feuerwerk-php-files-' . bin2hex(random_bytes(4));
|
||
mkdir("{$data}/projects", 0775, true);
|
||
$users = new UserStore("{$data}/users.json");
|
||
$users->put('ann', 'password1');
|
||
$users->put('bob', 'password2');
|
||
$sounds = new SoundStore("{$data}/sounds");
|
||
|
||
check('an account has no folder before its first upload', $users->folderOf('ann') === '');
|
||
$ann = $users->folderOf('ann', true);
|
||
check('the first upload gives it one', preg_match('/^' . UserStore::FOLDER_PATTERN . '$/', $ann) === 1, $ann);
|
||
check('and keeps it', $users->folderOf('ann', true) === $ann && $users->folderOf('ann') === $ann);
|
||
$users->put('ann', 'another password');
|
||
check('a new password keeps the folder', $users->folderOf('ann') === $ann);
|
||
$users->setRole('ann', 'admin');
|
||
check('so does a new role', $users->folderOf('ann') === $ann);
|
||
$users->rename('ann', 'anna');
|
||
check('and a new name', $users->folderOf('anna') === $ann);
|
||
$bob = $users->folderOf('bob', true);
|
||
check('another account gets another folder', $bob !== $ann);
|
||
check('the folders say whose they are', $users->folderOwners() === [$ann => 'anna', $bob => 'bob'], $users->folderOwners());
|
||
|
||
$mine = $sounds->store(wav('a'), 'jump.wav', $ann);
|
||
check('an upload lands in the folder and says so', $mine['file'] === $ann . '/' . hash('sha256', wav('a')) . '.wav', $mine);
|
||
check('the file is there', is_file("{$data}/sounds/{$mine['file']}"));
|
||
check('its name lies beside it', $mine['name'] === 'jump.wav');
|
||
check('the same bytes again are the same file', $sounds->store(wav('a'), 'jump.wav', $ann)['file'] === $mine['file']);
|
||
$theirs = $sounds->store(wav('a'), 'hop.wav', $bob);
|
||
check('the same bytes from another account are a file of its own', $theirs['file'] === $bob . '/' . basename($mine['file']), $theirs);
|
||
check('and do not rename the first', $sounds->list($ann, false, [])[0]['name'] === 'jump.wav');
|
||
// What was uploaded before #344 lies in the kind's own directory and belongs to nobody.
|
||
$loose = hash('sha256', wav('old')) . '.wav';
|
||
file_put_contents("{$data}/sounds/{$loose}", wav('old'));
|
||
|
||
check('a name with a folder is valid', SoundStore::isValidName($mine['file']));
|
||
check('one without is too', SoundStore::isValidName($loose));
|
||
foreach (["../{$loose}", "{$ann}/../{$loose}", "x/{$loose}", "{$ann}/{$ann}/{$loose}", "{$ann}/", strtoupper($ann) . "/{$loose}"] as $bad) {
|
||
check("{$bad} is not a stored name", !SoundStore::isValidName($bad));
|
||
}
|
||
check('the folder of a name', HashedFileStore::folderOf($mine['file']) === $ann && HashedFileStore::folderOf($loose) === '');
|
||
|
||
$owners = $users->folderOwners();
|
||
$files = static fn(array $list): array => array_column($list, 'file');
|
||
$listed = $sounds->list($ann, false, $owners);
|
||
check('an account lists its own files and nobody\'s', $files($listed) == [$mine['file'], $loose] || $files($listed) == [$loose, $mine['file']], $files($listed));
|
||
$byFile = array_column($listed, null, 'file');
|
||
check('its own say whose they are and may go', $byFile[$mine['file']]['owner'] === 'anna' && $byFile[$mine['file']]['deletable'] === true, $byFile[$mine['file']]);
|
||
check('nobody\'s say so and may not', $byFile[$loose]['owner'] === null && $byFile[$loose]['deletable'] === false, $byFile[$loose]);
|
||
check('an account without a folder sees nobody\'s only', $files($sounds->list('', false, $owners)) === [$loose]);
|
||
$all = array_column($sounds->list('', true, $owners), null, 'file');
|
||
check('an administrator lists every file', count($all) === 3, array_keys($all));
|
||
check('and may delete nobody\'s too', $all[$loose]['deletable'] === true && $all[$loose]['owner'] === null);
|
||
$users->remove('bob');
|
||
$orphans = array_column($sounds->list('', true, $users->folderOwners()), null, 'file');
|
||
check('a deleted account\'s files stay, with no owner name', $orphans[$theirs['file']]['owner'] === '', $orphans[$theirs['file']]);
|
||
|
||
// Which projects use a file: in the project, a snapshot or a release.
|
||
$project = static fn(string $id, string $owner, string $name, array $assets): array => [
|
||
'id' => $id, 'name' => $name, 'owner' => $owner, 'revision' => 1, 'project' => ['assets' => $assets],
|
||
];
|
||
$write = static function (string $path, array $doc): void {
|
||
@mkdir(dirname($path), 0775, true);
|
||
file_put_contents($path, json_encode($doc, JSON_UNESCAPED_SLASHES));
|
||
};
|
||
$usage = new FileUsage($data);
|
||
check('a file no project names is not used', $usage->of($mine['file']) === []);
|
||
$write("{$data}/projects/p1.json", $project('p1', 'anna', 'Garden', [['type' => 'sound', 'file' => $mine['file']]]));
|
||
check('a project naming it uses it', $usage->of($mine['file']) === [['id' => 'p1', 'owner' => 'anna', 'name' => 'Garden']], $usage->of($mine['file']));
|
||
check('the same bytes in another folder are another file', $usage->of($theirs['file']) === []);
|
||
check('a name without the folder is another file', $usage->of($loose) === []);
|
||
$write("{$data}/projects/p2.json", $project('p2', 'bob', 'Cave', [['type' => 'sound', 'file' => 'x']]));
|
||
$write("{$data}/snapshots/p2/s1.json", ['id' => 's1', 'project' => ['assets' => [['file' => $theirs['file']]]]]);
|
||
check('a snapshot counts for its project', array_column($usage->of($theirs['file']), 'id') === ['p2'], $usage->of($theirs['file']));
|
||
$write("{$data}/releases/" . str_repeat('a', 32) . '.json', ['projectId' => 'p3', 'project' => ['nested' => [[$loose]]]]);
|
||
$write("{$data}/projects/p3.json", $project('p3', 'anna', 'Garden', []));
|
||
check('so does a release, wherever in it the name is', array_column($usage->of($loose), 'id') === ['p3'], $usage->of($loose));
|
||
check('a name that is only part of a string is no use', !FileUsage::uses(['x' => "{$loose} "], $loose));
|
||
// Slashes escaped the way PHP writes them by default are found as well.
|
||
file_put_contents("{$data}/projects/p4.json", json_encode($project('p4', 'bob', 'Mine', [['file' => $mine['file']]])));
|
||
check('a document with escaped slashes counts', count($usage->of($mine['file'])) === 2, $usage->of($mine['file']));
|
||
|
||
$uses = [
|
||
['id' => 'p1', 'owner' => 'anna', 'name' => 'Garden'],
|
||
['id' => 'p3', 'owner' => 'anna', 'name' => 'Garden'],
|
||
['id' => 'p4', 'owner' => 'bob', 'name' => 'Mine'],
|
||
];
|
||
check(
|
||
'the refusal names the asker\'s projects only',
|
||
FileUsage::refusal($uses, 'anna', false) === 'Still used by “Garden” (2 projects) and a project of another account, counting snapshots and releases',
|
||
FileUsage::refusal($uses, 'anna', false),
|
||
);
|
||
check(
|
||
'an administrator learns whose the others are',
|
||
FileUsage::refusal($uses, 'anna', true) === 'Still used by “Garden” (2 projects) and “Mine” of bob, counting snapshots and releases',
|
||
FileUsage::refusal($uses, 'anna', true),
|
||
);
|
||
check(
|
||
'somebody with none of them learns only how many',
|
||
FileUsage::refusal($uses, 'carl', false) === 'Still used by 3 projects of other accounts, counting snapshots and releases',
|
||
FileUsage::refusal($uses, 'carl', false),
|
||
);
|
||
|
||
check('deleting a file removes it', $sounds->delete($mine['file']) && !is_file("{$data}/sounds/{$mine['file']}"));
|
||
check('and its name', !is_file("{$data}/sounds/{$mine['file']}.json"));
|
||
check('the same bytes in the other folder stay', $sounds->exists($theirs['file']));
|
||
check('deleting it again says there was none', $sounds->delete($mine['file']) === false);
|
||
check('a path out of the store is no file to delete', $sounds->delete('../users.json') === false && is_file("{$data}/users.json"));
|
||
|
||
exec('rm -rf ' . escapeshellarg($data));
|
||
|
||
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
|
||
exit($failed === 0 ? 0 : 1);
|