fix(security): bound inbound-mail resources, redact secrets from logs (stable) (#965)
* fix(security): bound inbound-mail resources, redact secrets from logs (GHSA-2qf9, pgmp, r794) GHSA-2qf9 — emailIntakeService downloaded, parsed and persisted every message with no size, attachment-count or attachment-byte limit, reachable unauthenticated by anyone who can email the operator's mailbox: - fetch the envelope with `size` (same cheap pass) and refuse an oversized message BEFORE downloading its source; - cap attachment count and cumulative attachment bytes; - limits env-overridable, defaults generous for real supplier invoices. The teeth were in the dedup key. received_emails.message_id is varchar(512) UNIQUE, and the failure path wrote `err-<uid>-<Date.now()>`, which can never match the envelope-derived messageId the dedup pass compares against — so an oversized (or overlong-Message-ID) mail was re-downloaded every poll forever, and an OOM-kill/restart just resumed the loop. Size-skips are now recorded under the REAL message id, and overlong ids collapse to a stable sha256 key that always fits the column. GHSA-pgmp / r794 — new sanitizeForLog() util (key-name deny-set, recursive, cycle-safe) applied to the three request-body log sites in adminEvents/crud.js, plus sanitizeValidationErrors() because express-validator's errors.array() embeds the SUBMITTED value per field — a rejected plaintext password was still logged. Scope is wider than filed: the update path also logged client_password_hash and a LIVE client_share_token bearer credential. Also: the one-time setup token was logged at warn AND printed to stdout on every first boot, putting a live first-admin credential in combined.log, security.log and `docker logs`. It is now written to the 0600 token file and only surfaced when that write fails — the last-resort path it existed for. (stable) * fix(security): codex round 3 — repair the first-run token recovery flow (GHSA-r794) Two regressions from keeping the setup token out of the logs. 1. server.js decided whether to print the token by calling existsSync() on the candidate path. That answers a different question than "did the write succeed": a stale, read-only or directory-shaped SETUP_TOKEN reports as present, so the banner suppressed the live token and pointed the operator at content that is not it — leaving the current token only in combined.log under default production logging. setupService now records the path the write actually produced and exposes it via writtenSetupTokenFile(). 2. The setup screen, its EN/DE strings, README, SIMPLE_SETUP and .env.example all still told first-time users to run `docker compose logs backend | grep -i "setup token"`. On the normal path that command now returns a path banner and no credential, so the documented browser-first onboarding could not be completed. They now point at `docker compose exec backend cat /app/data/SETUP_TOKEN`, with the log fallback described as what it is — the failure path. Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me (cherry picked from commit 9a54b6f0231c3285df4c4865eb846e63e1ed0dda) --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
11f9f584de
commit
ccab9024d4
+16
-4
@@ -1005,8 +1005,15 @@ async function startServer() {
|
||||
// Runs AFTER install-from-backup so a restored instance (which repopulates
|
||||
// admin_users) never prints a throwaway token. Best-effort — never blocks boot.
|
||||
let setupToken = null;
|
||||
let setupTokenFile = null;
|
||||
try {
|
||||
setupToken = await require('./src/services/setupService').ensureSetupToken();
|
||||
const setupSvc = require('./src/services/setupService');
|
||||
setupToken = await setupSvc.ensureSetupToken();
|
||||
// The path the write ACTUALLY produced (null when it failed). existsSync
|
||||
// on the candidate answered a different question and reported success
|
||||
// for a stale, read-only or directory-shaped SETUP_TOKEN — suppressing
|
||||
// the token here while pointing the operator at content that is not it.
|
||||
setupTokenFile = setupSvc.writtenSetupTokenFile();
|
||||
} catch (err) {
|
||||
logger.warn(`[setup] ensureSetupToken skipped: ${err.message}`);
|
||||
}
|
||||
@@ -1026,12 +1033,17 @@ async function startServer() {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
|
||||
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
|
||||
// First-run: print the one-time setup token to STDOUT (the file logger
|
||||
// doesn't reach `docker logs`), as the last + most visible thing at boot.
|
||||
// First-run banner. Print the TOKEN ITSELF only when the 0600 token file
|
||||
// could not be written — otherwise this lands a live first-admin
|
||||
// credential in `docker logs` / journald, which is the leak GHSA-r794's
|
||||
// sweep turned up. When the file exists we point at it instead.
|
||||
if (setupToken) {
|
||||
const url = `${process.env.ADMIN_URL || 'http://localhost:3000'}/admin`;
|
||||
const line = '='.repeat(64);
|
||||
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n One-time setup token: ${setupToken}\n (also saved to data/SETUP_TOKEN)\n${line}\n`);
|
||||
const secretLine = setupTokenFile
|
||||
? ` Setup token saved to: ${setupTokenFile}\n (read it there — deliberately not printed)`
|
||||
: ` One-time setup token: ${setupToken}\n (could not write the token file, so it is shown here)`;
|
||||
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n${secretLine}\n${line}\n`);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user