Severity: Medium (defense-in-depth). A basket of lower-severity findings from the v0.8.0 security sweep. Each is small; grouped to track together. Split out if any grows.
1. Global error handler leaks raw error.message in production
src/index.ts:158 — stacks are dev-only (good), but the raw exception message is returned on every unhandled 500 in prod. Postgres/driver errors embed table/column/query fragments → schema disclosure. Fix: return a generic "Internal server error" for the 500 branch; keep mapped errors (SuppressedRecipientError, UnauthorizedSenderError); log detail server-side only.
2. Session cookie missing Secure; CSRF relies solely on SameSite=Lax
src/pages/pages.plugin.tsx:266 — bm_session=…; HttpOnly; SameSite=Lax; Path=/dashboard — no Secure (cookie sent over plaintext HTTP if TLS is ever missing → MITM session theft). Every state-mutating dashboard action is a POST with no CSRF token and no Origin/Referer check; SameSite=Lax is the only defense. Fix: add Secure (at least off-localhost) + an Origin/Referer allowlist check or per-session CSRF token on POST handlers.
3. SESSION_SECRET defaults to a fresh random UUID per process
src/config.ts:266 — regenerated every restart, differs per replica (sessions silently invalidate; multi-replica rejects each other's cookies → operators "fix" it with a weak hardcoded value). Session payload is <ts>.<hmac> with no session id → no revocation, password change doesn't invalidate cookies. Fix: require SESSION_SECRET in production (mirror the DASHBOARD_PASSWORD prod guard), fail fast if unset.
4. SMTP submission allows plaintext AUTH by default
src/modules/smtp-submission/services/smtp-submission.service.ts:190 — allowInsecureAuth: true when TLS unset (default), and the password is a bm_live_ key. Documented "trusted network only," but on by default → one exposed port from leaking full-privilege keys in cleartext. Fix: default to false unless SMTP_SUBMISSION_ALLOW_INSECURE=true; recommend STARTTLS.
5. cc/bcc unvalidated (CRLF header-injection defense-in-depth)
src/modules/emails/dtos/send-email.dto.ts:22 — cc/bcc are bare t.String() (no format: "email", no length cap), unlike to/from. Injection is currently neutralized only by nodemailer's address parser. Fix: apply format: "email" + length caps to cc/bcc; strip \r/\n from subject and the List-Unsubscribe url/mailto as defense-in-depth.
6. No rate limit on dashboard actions that do expensive work
POST /dashboard/domains/:id/verify (pages.plugin.tsx:969) runs live DNS lookups with no throttle; the HTTP limiter is per-API-key and only on /api/v1/*. In-memory limiter is also per-replica. Fix: throttle the DNS-verify action; consider a shared-store limiter for multi-replica.
Informational (no action needed)
- Outbound MX TLS
rejectUnauthorized: false (mailer.service.ts:265) — industry norm for opportunistic direct-to-MX; only disabled-TLS site in the codebase. Document as accepted.
- Unsalted SHA-256 API-key hashing — fine for 128-bit random tokens (consider 256-bit for margin).
- Logger has no generic secret redaction; adequate by convention today but fragile — consider a denylist redactor in
formatLog.
Acceptance
Severity: Medium (defense-in-depth). A basket of lower-severity findings from the v0.8.0 security sweep. Each is small; grouped to track together. Split out if any grows.
1. Global error handler leaks raw
error.messagein productionsrc/index.ts:158— stacks are dev-only (good), but the raw exception message is returned on every unhandled 500 in prod. Postgres/driver errors embed table/column/query fragments → schema disclosure. Fix: return a generic"Internal server error"for the 500 branch; keep mapped errors (SuppressedRecipientError,UnauthorizedSenderError); log detail server-side only.2. Session cookie missing
Secure; CSRF relies solely on SameSite=Laxsrc/pages/pages.plugin.tsx:266—bm_session=…; HttpOnly; SameSite=Lax; Path=/dashboard— noSecure(cookie sent over plaintext HTTP if TLS is ever missing → MITM session theft). Every state-mutating dashboard action is a POST with no CSRF token and no Origin/Referer check;SameSite=Laxis the only defense. Fix: addSecure(at least off-localhost) + an Origin/Referer allowlist check or per-session CSRF token on POST handlers.3.
SESSION_SECRETdefaults to a fresh random UUID per processsrc/config.ts:266— regenerated every restart, differs per replica (sessions silently invalidate; multi-replica rejects each other's cookies → operators "fix" it with a weak hardcoded value). Session payload is<ts>.<hmac>with no session id → no revocation, password change doesn't invalidate cookies. Fix: requireSESSION_SECRETin production (mirror theDASHBOARD_PASSWORDprod guard), fail fast if unset.4. SMTP submission allows plaintext AUTH by default
src/modules/smtp-submission/services/smtp-submission.service.ts:190—allowInsecureAuth: truewhen TLS unset (default), and the password is abm_live_key. Documented "trusted network only," but on by default → one exposed port from leaking full-privilege keys in cleartext. Fix: default to false unlessSMTP_SUBMISSION_ALLOW_INSECURE=true; recommend STARTTLS.5.
cc/bccunvalidated (CRLF header-injection defense-in-depth)src/modules/emails/dtos/send-email.dto.ts:22—cc/bccare baret.String()(noformat: "email", no length cap), unliketo/from. Injection is currently neutralized only by nodemailer's address parser. Fix: applyformat: "email"+ length caps tocc/bcc; strip\r/\nfromsubjectand theList-Unsubscribeurl/mailto as defense-in-depth.6. No rate limit on dashboard actions that do expensive work
POST /dashboard/domains/:id/verify(pages.plugin.tsx:969) runs live DNS lookups with no throttle; the HTTP limiter is per-API-key and only on/api/v1/*. In-memory limiter is also per-replica. Fix: throttle the DNS-verify action; consider a shared-store limiter for multi-replica.Informational (no action needed)
rejectUnauthorized: false(mailer.service.ts:265) — industry norm for opportunistic direct-to-MX; only disabled-TLS site in the codebase. Document as accepted.formatLog.Acceptance
SECURITY.md/THREAT_MODEL.mdupdated where relevant.