Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,9 @@ This is a combined RMS (Resource Management System) backend + marketing site for
- **Rate limiting is in-memory:** `server/rate-limit.ts` uses a Map — resets on server restart, doesn't work across multiple instances. Acceptable for single-instance VPS deployment.
- **No email queue retry backoff:** `app/api/cron/process-emails/route.ts` retries failed emails up to 3 times with no delay between attempts — should add exponential backoff if email failures become common
- **Portal token is permanent:** Client portal tokens (cuid) never expire and can't be rotated without DB manual intervention — add rotation mechanism before handling sensitive client data
- **Accepted-risk dependency vulns (CI-allowlisted):** Several transitive advisories are open with **no fix available** and are deliberately allowlisted in `scripts/audit-check.mjs` — the `npm audit` CI gate still fails on any *new* high/critical advisory, just not these. Re-triage if `EmailPayload` or the SMTP transport options in `server/email/index.ts` ever widen.
- **nodemailer — 6 advisories (incl. high-severity):** GHSA-c7w3-x93f-qmm8 (`envelope.size`), GHSA-vvjj-xcjg-gr5g (transport `name`), GHSA-268h-hp4c-crq3 (`List-*` header CRLF), GHSA-wqvq-jvpq-h66f (`jsonTransport` bypass), GHSA-r7g4-qg5f-qqm2 (OAuth2 token TLS), GHSA-p6gq-j5cr-w38f (`raw` option file-read/SSRF). All unreachable: `sendEmail()` accepts only the `EmailPayload` allowlist (`from/to/subject/html/text/replyTo`) and the transport sets only `host/port/secure/auth{user,pass}` — so we never set `envelope`, transport `name`, `List-*` headers, `raw`, `jsonTransport`, or OAuth2 auth. Fix needs nodemailer 8+, but next-auth's `@auth/core` peer-dep pins us to 7. Revisit when next-auth 5 stable supports nodemailer 8+.
- **Accepted-risk dependency vulns (CI-allowlisted):** One transitive advisory is open with **no fix available** and is deliberately allowlisted in `scripts/audit-check.mjs` — the `npm audit` CI gate still fails on any *new* high/critical advisory, just not this one.
- **postcss GHSA-qx2v-qp2m-jg93** (moderate, XSS in CSS stringify) — transitive via Next.js's bundled postcss; not exploitable through our surface (no untrusted input to postcss stringify). Resolves when Next bumps its bundled postcss.
- **nodemailer forced to 9 via `overrides`:** the 6 nodemailer advisories (incl. one high) were *resolved* by upgrading to nodemailer 9. next-auth's `@auth/core` declares an **optional** `nodemailer@^7` peer (only for its Email provider, which we don't use — auth is Credentials-only in `server/auth/index.ts`), so `package.json` `overrides` forces the whole tree to 9 — runtime-safe, no `--legacy-peer-deps`. Drop the override when next-auth's peer range includes nodemailer 9+.

### Testing & Deployment

Expand Down
10 changes: 10 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,13 @@ The AI framework is the cleaner reference implementation of the pattern.
**Why:** A cluster of nodemailer advisories (one high) has no fix — next-auth's `@auth/core` peer-dep pins nodemailer to 7 — so `--audit-level=high` left the gate permanently red, blocking every PR. Each was triaged unreachable against the actual code (`server/email/index.ts` exposes only the `EmailPayload` allowlist; the transport sets no `envelope`/`name`/`raw`/`jsonTransport`/OAuth2). Allowlisting the specific GHSAs keeps the gate meaningful — a new, unrelated high/critical still fails the build.

**Alternatives considered:** lower to `--audit-level=critical` (would silently ignore future *high* vulns — too loose); `|| true` on the step (disables the gate); add `audit-ci`/`better-npm-audit` (a new dependency for what ~40 lines of zero-dep JS does). The allowlist must shrink as fixes land — revisit when next-auth 5 supports nodemailer 8+.

---

### 2026-06-19: Force nodemailer 9 via `overrides` (next-auth's peer is optional)

**Decision:** `package.json` `overrides` pins the whole tree to nodemailer 9 (matching our direct dep), overriding `@auth/core`'s `nodemailer@^7` peer.

**Why:** Upgrading nodemailer 7→9 fixes the 6 advisories (one high) that had no other fix, so the allowlist shrinks to postcss only. `@auth/core`'s nodemailer peer is **optional** and used only by next-auth's Email provider — we use the **Credentials** provider only (`server/auth/index.ts`), so next-auth never loads nodemailer. The override is therefore runtime-safe and lets `npm ci` resolve without `--legacy-peer-deps` (which hides real breakage — see LESSONS).

**Alternatives:** bump next-auth to a beta whose peer allows nodemailer 9 (rejected — bumping the auth library is riskier than a scoped override); `--legacy-peer-deps` in CI (rejected). Remove the override once next-auth's peer range includes nodemailer 9+.
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"overrides": {
"nodemailer": "$nodemailer"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.2",
"@base-ui/react": "^1.5.0",
Expand All @@ -36,7 +39,7 @@
"lucide-react": "^1.18.0",
"next": "^16.2.9",
"next-auth": "^5.0.0-beta.31",
"nodemailer": "^7.0.13",
"nodemailer": "^9.0.1",
"pino": "^10.3.1",
"prisma": "^6.19.2",
"react": "19.2.7",
Expand All @@ -53,7 +56,7 @@
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^25",
"@types/nodemailer": "^7.0.11",
"@types/nodemailer": "^8.0.1",
"@types/pino": "^7.0.5",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand Down
18 changes: 5 additions & 13 deletions scripts/audit-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,19 @@
// advisory EXCEPT a small allowlist of triaged, no-fix-available advisories
// (documented in CLAUDE.md "Known Issues"). This keeps the gate meaningful — a
// new, unrelated high/critical vuln still fails the build — without it being
// permanently red over transitive advisories we cannot patch (nodemailer is
// pinned to 7 by next-auth's @auth/core peer dependency).
// permanently red over a transitive advisory we cannot patch without
// downgrading a framework (currently only postcss, bundled by Next.js).
//
// Usage (see .github/workflows/ci.yml):
// npm audit --json --omit=dev > npm-audit.json || true
// node scripts/audit-check.mjs npm-audit.json

import { readFileSync } from "node:fs";

// Each entry is accepted because the vulnerable code path is unreachable given
// how server/email/index.ts uses nodemailer: sendEmail() accepts only the
// EmailPayload allowlist (from/to/subject/html/text/replyTo) and the transport
// sets only host/port/secure/auth{user,pass}. Re-triage if EmailPayload or the
// transport options ever widen.
// Each entry must be a triaged, no-fix-available advisory that is unreachable
// through our application surface. Shrink as fixes land. (The nodemailer cluster
// was removed once nodemailer 9 patched it — see package.json `overrides`.)
const ALLOW = new Map([
["GHSA-c7w3-x93f-qmm8", "nodemailer envelope.size injection — we never set envelope"],
["GHSA-vvjj-xcjg-gr5g", "nodemailer transport name CRLF — we never set name"],
["GHSA-268h-hp4c-crq3", "nodemailer List-* header CRLF — we never set headers/list"],
["GHSA-wqvq-jvpq-h66f", "nodemailer jsonTransport bypass — SMTP transport only"],
["GHSA-r7g4-qg5f-qqm2", "nodemailer OAuth2 TLS — we use auth:{user,pass}, not OAuth2"],
["GHSA-p6gq-j5cr-w38f", "nodemailer raw option file-read/SSRF — not in EmailPayload"],
["GHSA-qx2v-qp2m-jg93", "postcss stringify XSS — transitive via Next; no untrusted input"],
]);

Expand Down