ccab9024d4
* 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 <paul@MacStudio-von-Paul.local>
85 lines
2.8 KiB
JavaScript
85 lines
2.8 KiB
JavaScript
/**
|
|
* Redact credential-bearing fields before an object reaches the logs
|
|
* (GHSA-pgmp / GHSA-r794).
|
|
*
|
|
* Event create/update routes logged the whole request body. That body can
|
|
* carry a gallery `password`, a bcrypt `client_password_hash`, and — when
|
|
* `regenerate_client_token` is set — a freshly minted `client_share_token`,
|
|
* which is a LIVE bearer credential for client gallery access, not a hash.
|
|
*
|
|
* Deliberately key-name based rather than value-shaped: a deny-set of names is
|
|
* predictable and cheap, whereas guessing at "this looks like a secret" both
|
|
* misses and false-positives. Matching is case-insensitive and substring-based
|
|
* so `client_password_hash` and `smtp_pass` are caught without enumerating
|
|
* every variant.
|
|
*/
|
|
|
|
const DENY_FRAGMENTS = [
|
|
'password',
|
|
'passwd',
|
|
'secret',
|
|
'token',
|
|
'api_key',
|
|
'apikey',
|
|
'authorization',
|
|
'credential',
|
|
'private_key',
|
|
];
|
|
|
|
const REDACTED = '[redacted]';
|
|
|
|
function isSensitiveKey(key) {
|
|
const k = String(key).toLowerCase();
|
|
return DENY_FRAGMENTS.some((fragment) => k.includes(fragment));
|
|
}
|
|
|
|
/**
|
|
* Return a copy of `value` with sensitive fields replaced by `[redacted]`.
|
|
* Non-objects pass through unchanged. Cycles are handled so a caller can't
|
|
* turn a log line into an infinite loop.
|
|
*
|
|
* @param {*} value
|
|
* @param {number} [depth] internal recursion guard
|
|
* @param {WeakSet} [seen] internal cycle guard
|
|
*/
|
|
function sanitizeForLog(value, depth = 0, seen = new WeakSet()) {
|
|
if (value === null || typeof value !== 'object') return value;
|
|
if (depth > 6) return '[truncated]';
|
|
if (seen.has(value)) return '[circular]';
|
|
seen.add(value);
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.map((v) => sanitizeForLog(v, depth + 1, seen));
|
|
}
|
|
|
|
const out = {};
|
|
for (const [key, val] of Object.entries(value)) {
|
|
out[key] = isSensitiveKey(key) ? REDACTED : sanitizeForLog(val, depth + 1, seen);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Redact express-validator's `errors.array()` before logging.
|
|
*
|
|
* Each entry carries the SUBMITTED value under `value`, keyed by `path`. A
|
|
* password that fails the length check therefore lands in the log in plaintext
|
|
* — sanitizing only `req.body` does not close that (GHSA-pgmp / r794).
|
|
*
|
|
* @param {Array} errors output of validationResult(req).array()
|
|
*/
|
|
function sanitizeValidationErrors(errors) {
|
|
if (!Array.isArray(errors)) return errors;
|
|
return errors.map((err) => {
|
|
if (!err || typeof err !== 'object') return err;
|
|
const field = err.path || err.param;
|
|
if (field && isSensitiveKey(field)) {
|
|
return { ...err, value: REDACTED };
|
|
}
|
|
// Even for a non-sensitive field the value may be an object carrying one.
|
|
return 'value' in err ? { ...err, value: sanitizeForLog(err.value) } : err;
|
|
});
|
|
}
|
|
|
|
module.exports = { sanitizeForLog, sanitizeValidationErrors, isSensitiveKey };
|