Lock the server down before the editor goes public (#427)
feuerwerk / gate (push) Failing after 5m1s
feuerwerk / suite (push) Canceled after 4m52s

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:
adminandClaude Opus 5.5 committed 2026-10-15 23:05:32 +02:00
1 parent a9e0b6d9fa
commit cfd6d7a8df
27 files changed
+928 -37

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ feuerwerk_serve() {
mkdir -p "$root/data/projects" "$root/data/textures"
node "$root/tools/ci/project-shells.mjs"
# The sign-in checks type wrong passwords on purpose, and every request here comes from one
# address: two runs within 15 minutes would lock the second one out (#427).
rm -rf "$root/data/sign-ins"
# Since #177 the API needs a session, and the check tools sign in as `e2e`. A fresh checkout has
# no accounts at all, so the job makes the one it needs, with a password of its own.
+19 -2
View File
@@ -563,6 +563,7 @@ export async function runChecks(options, body) {
console.log(`browser: ${where}`);
const consoleLog = [];
const refused = [];
let failures = 0;
let checkCount = 0;
function check(name, ok, detail = '') {
@@ -625,14 +626,26 @@ export async function runChecks(options, body) {
mobile: false,
});
await requireGpu(cdp);
// The built pages carry a Content-Security-Policy (#427), and what it refuses may break nothing a
// check looks at. Chrome says so in its log for most refusals, in the page's own event for some
// (WebAssembly), and the event of a worker only in the log.
await cdp.send('Log.enable');
await cdp.send('Page.addScriptToEvaluateOnNewDocument', {
source: `addEventListener('securitypolicyviolation', (e) => console.warn('[csp] ' + e.violatedDirective + ' refused ' + (e.blockedURI || 'inline')), true);`,
});
cdp.on((msg) => {
if (msg.method === 'Runtime.consoleAPICalled')
consoleLog.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
if (msg.method === 'Runtime.consoleAPICalled') {
const text = msg.params.args.map((a) => a.value ?? a.description).join(' ');
consoleLog.push(text);
if (text.startsWith('[csp] ')) refused.push(text);
}
if (msg.method === 'Runtime.exceptionThrown')
consoleLog.push(
'EXCEPTION ' +
(msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text),
);
if (msg.method === 'Log.entryAdded' && /Content Security Policy/i.test(msg.params.entry.text))
refused.push(msg.params.entry.text);
});
await body(makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }));
ws.close();
@@ -652,6 +665,10 @@ export async function runChecks(options, body) {
stopBrowser();
});
}
// Reported only when it happens, so a passing run keeps its count of checks; and also after a step
// that threw, which a refusal is the likeliest cause of.
if (refused.length)
check('the pages stay within their Content-Security-Policy', false, [...new Set(refused)].slice(0, 3));
if (consoleLog.length) console.log('--- console ---\n' + consoleLog.slice(0, 20).join('\n'));
reportedSummary = true;
console.log(failures ? `${failures} FAILED` : `ALL PASSED – ${checkCount} checks`);
+5
View File
@@ -1,6 +1,11 @@
<?php
declare(strict_types=1);
// A command-line script: under a web server it does nothing (#427).
if (PHP_SAPI !== 'cli') {
exit(1);
}
/**
* Creates an account or sets the password of one that exists – the way to the first administrator,
* who can then manage the others in the editor (#179).
+5
View File
@@ -1,6 +1,11 @@
<?php
declare(strict_types=1);
// A command-line script: under a web server it does nothing (#427).
if (PHP_SAPI !== 'cli') {
exit(1);
}
/**
* Gives every uploaded image, font and sound a project uses to the account using it (#344, once).
*
+5
View File
@@ -1,6 +1,11 @@
<?php
declare(strict_types=1);
// A command-line script: under a web server it does nothing (#427).
if (PHP_SAPI !== 'cli') {
exit(1);
}
/**
* The video worker (#402): converts uploaded videos, one after the other, for as long as it runs.
*
+13 -1
View File
@@ -4,6 +4,10 @@
#
# It works on the checkout itself (the folder nginx serves), as www-data like the PHP container, so
# the files it writes belong to the same user. It needs no network.
#
# ffmpeg reads files strangers uploaded, so the container is kept small (#427): the checkout is
# read-only and only data/videos can be written, there are no capabilities, and memory and processes
# have a ceiling. A broken decoder could fill that one folder, not change the API or the pages.
name: feuerwerk-video
services:
@@ -15,7 +19,15 @@ services:
user: '33:33'
working_dir: /srv/feuerwerk
volumes:
- ../..:/srv/feuerwerk
- ../..:/srv/feuerwerk:ro
- ../../data/videos:/srv/feuerwerk/data/videos
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt: ['no-new-privileges:true']
command: ['php', 'tools/video-worker.php', '/srv/feuerwerk/data']
# A 1080p conversion takes all four cores otherwise, and the web stack shares them.
cpus: 3
mem_limit: 2g
pids_limit: 256