Lock the server down before the editor goes public (#427)
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>
This commit is contained in:
1 parent
a9e0b6d9fa
commit
cfd6d7a8df
27 files changed
+928
-37
No files matched your search
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// A command-line script: under a web server it does nothing (#427).
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The brake on wrong passwords and the quota of an account (#427), on a scratch directory with a
|
||||
* clock of its own. Plain PHP like releases.test.php; CI runs it with `php tests/php/limits.test.php`
|
||||
* and reads the last line.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../../api/lib/AtomicFile.php';
|
||||
require __DIR__ . '/../../api/lib/LoginThrottle.php';
|
||||
require __DIR__ . '/../../api/lib/Quota.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";
|
||||
}
|
||||
|
||||
$dir = sys_get_temp_dir() . '/feuerwerk-php-limits-' . bin2hex(random_bytes(4));
|
||||
mkdir($dir);
|
||||
|
||||
// --- LoginThrottle ---------------------------------------------------------------------------------
|
||||
|
||||
$now = 1_000_000;
|
||||
$throttle = new LoginThrottle("{$dir}/sign-ins", static function () use (&$now): int {
|
||||
return $now;
|
||||
});
|
||||
$window = LoginThrottle::WINDOW_SECONDS;
|
||||
|
||||
check('an address that never failed may try', $throttle->waitFor('203.0.113.7') === 0);
|
||||
for ($i = 1; $i < LoginThrottle::MAX_FAILURES; $i++) {
|
||||
$throttle->fail('203.0.113.7');
|
||||
$now += 10;
|
||||
}
|
||||
check('four failures still let it try', $throttle->waitFor('203.0.113.7') === 0, $throttle->waitFor('203.0.113.7'));
|
||||
$wait = $throttle->fail('203.0.113.7');
|
||||
check('the fifth locks it for the whole window', $wait === $window, $wait);
|
||||
check('and asking says so', $throttle->waitFor('203.0.113.7') === $window);
|
||||
check('another address is not touched', $throttle->waitFor('198.51.100.1') === 0);
|
||||
|
||||
$now += $window - 1;
|
||||
check('one second before the end it still waits', $throttle->waitFor('203.0.113.7') === 1, $throttle->waitFor('203.0.113.7'));
|
||||
$now += 1;
|
||||
check('15 minutes after the last failure it may try again', $throttle->waitFor('203.0.113.7') === 0);
|
||||
$throttle->fail('203.0.113.7');
|
||||
check('and the old failures no longer count', $throttle->waitFor('203.0.113.7') === 0);
|
||||
|
||||
// Failures spread wider than the window never add up to five.
|
||||
$now += $window;
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$throttle->fail('192.0.2.9');
|
||||
$now += (int) ($window / 4) + 1;
|
||||
}
|
||||
check('failures spread over more than 15 minutes never lock', $throttle->waitFor('192.0.2.9') === 0);
|
||||
|
||||
check(
|
||||
'the rule as a function: five within the window wait for the rest of it',
|
||||
LoginThrottle::wait([100, 200, 300, 400, 500], 600) === 500 + $window - 600,
|
||||
);
|
||||
check('four do not', LoginThrottle::wait([100, 200, 300, 400], 600) === 0);
|
||||
check(
|
||||
'nor five of which the first is a whole window before the last',
|
||||
LoginThrottle::wait([500 - $window, 200, 300, 400, 500], 600) === 0,
|
||||
);
|
||||
|
||||
$files = glob("{$dir}/sign-ins/*.json") ?: [];
|
||||
check('one file per address that failed, named by its hash', count($files) === 2 &&!str_contains(implode('', $files), '203.0.113.7'), $files);
|
||||
check('which the web server may not read', (fileperms($files[0]) & 0o007) === 0);
|
||||
|
||||
touch($files[0], $now - 2 * $window - 1);
|
||||
check('pruning drops an address whose failures are all old', $throttle->prune() >= 1 && !is_file($files[0]));
|
||||
|
||||
// --- Quota -----------------------------------------------------------------------------------------
|
||||
|
||||
$data = "{$dir}/data";
|
||||
mkdir("{$data}/projects", 0775, true);
|
||||
$users = new UserStore("{$data}/users.json");
|
||||
$users->put('ann', 'password1');
|
||||
$folder = $users->folderOf('ann', true);
|
||||
mkdir("{$data}/sounds/{$folder}", 0775, true);
|
||||
file_put_contents("{$data}/sounds/{$folder}/" . str_repeat('a', 64) . '.wav', str_repeat("\0", 3000));
|
||||
mkdir("{$data}/videos/{$folder}", 0775, true);
|
||||
file_put_contents("{$data}/videos/{$folder}/" . str_repeat('b', 64) . '.mp4', str_repeat("\0", 2000));
|
||||
$doc = (new Store("{$data}/projects", 'ann'))->create('Mine');
|
||||
$projectBytes = filesize("{$data}/projects/{$doc['id']}.json");
|
||||
mkdir("{$data}/snapshots/{$doc['id']}", 0775, true);
|
||||
file_put_contents("{$data}/snapshots/{$doc['id']}/s1.json", str_repeat('x', 500));
|
||||
// Somebody else's things do not count, nor do the files that belong to nobody.
|
||||
$theirs = (new Store("{$data}/projects", 'bob'))->create('Theirs');
|
||||
file_put_contents("{$data}/sounds/" . str_repeat('c', 64) . '.wav', str_repeat("\0", 9000));
|
||||
|
||||
$quota = new Quota($data, 10_000);
|
||||
$used = $quota->usage('ann', $folder);
|
||||
check('usage adds the uploads, the projects and their snapshots', $used === 3000 + 2000 + $projectBytes + 500, [$used, $projectBytes]);
|
||||
check('what fits is let through', $quota->refusal('ann', $folder, 10_000 - $used) === null);
|
||||
$refusal = $quota->refusal('ann', $folder, 10_000 - $used + 1);
|
||||
check('one byte more is refused, saying how full the account is', is_string($refusal) && str_contains($refusal, 'Not enough space'), $refusal);
|
||||
check('a write that makes nothing larger always fits', (new Quota($data, 1))->refusal('ann', $folder, 0) === null);
|
||||
check(
|
||||
'an account without uploads counts its projects only',
|
||||
$quota->usage('bob', '') === filesize("{$data}/projects/{$theirs['id']}.json"),
|
||||
$quota->usage('bob', ''),
|
||||
);
|
||||
check('the limit is 1 GB unless a test says otherwise', Quota::BYTES === 1024 * 1024 * 1024);
|
||||
|
||||
exec('rm -rf ' . escapeshellarg($dir));
|
||||
|
||||
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
|
||||
exit($failed === 0 ? 0 : 1);
|
||||
Reference in new issue
Block a user