audit: Tier 1/3/4/5/7 batch — SSE, gates, dedup, URL escape, FK constraints
Tier 1.6: S3Backend.put now sets ServerSideEncryption=AES256 — closes the cleartext-at-rest gap for signed contracts, GDPR exports, pg_dumps. Tier 3.7: New safeUrl() helper in lib/email/shell.ts. Scheme allow-list (http/https/mailto/tel/relative only — javascript:/data:/vbscript:/file: rewritten to about:blank) + HTML-attribute escape. Retrofitted across all 7 transactional templates (crm-invite, portal-auth, document-signing, notification-digest, residential-inquiry, admin-email-change). Tier 4.2: /api/v1/alerts GET now gated on admin.view_audit_log. Tier 4.3: Documenso webhook handler emits captureErrorEvent on catch. Admin/errors no longer silent on webhook crashes. Tier 4.6: Inquiry-funnel email dedup is now case-insensitive (LOWER(value)) and stores normalized email on insert. Capital-letter resubmissions no longer spawn duplicate client+yacht+interest rows. Tier 5.6 + data-model H1: migration 0056 adds FK user_permission_overrides.user_id → user(id) cascade, same for user_port_roles.userId, plus partial unique index on user_email_changes pending rows. Tier 7.6: @types/node bumped from ^25 to ^20.19.0 — matches the runtime. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
docs/AUDIT-PARKED-QUESTIONS.md
Normal file
6
docs/AUDIT-PARKED-QUESTIONS.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Parked questions — needs product/business decision
|
||||
|
||||
Items from the 33-agent audit that I deliberately left for you to decide on. Each one has the finding, why I parked it, and the proposed options.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/iso-3166-2": "^1.0.4",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@@ -249,8 +249,8 @@ importers:
|
||||
specifier: ^3.4.6
|
||||
version: 3.4.6
|
||||
'@types/node':
|
||||
specifier: ^25.6.2
|
||||
version: 25.6.2
|
||||
specifier: ^20.19.0
|
||||
version: 20.19.41
|
||||
'@types/nodemailer':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
@@ -310,7 +310,7 @@ importers:
|
||||
version: 6.0.3
|
||||
vitest:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))
|
||||
version: 4.1.5(@types/node@20.19.41)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1857,11 +1857,8 @@ packages:
|
||||
'@types/mailparser@3.4.6':
|
||||
resolution: {integrity: sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==}
|
||||
|
||||
'@types/node@22.19.18':
|
||||
resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==}
|
||||
|
||||
'@types/node@25.6.2':
|
||||
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==}
|
||||
'@types/node@20.19.41':
|
||||
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
|
||||
|
||||
'@types/nodemailer@8.0.0':
|
||||
resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==}
|
||||
@@ -4907,9 +4904,6 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.19.2:
|
||||
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
|
||||
|
||||
unicode-properties@1.4.1:
|
||||
resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
|
||||
|
||||
@@ -6506,7 +6500,7 @@ snapshots:
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 22.19.18
|
||||
'@types/node': 20.19.41
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
@@ -6544,24 +6538,20 @@ snapshots:
|
||||
|
||||
'@types/mailparser@3.4.6':
|
||||
dependencies:
|
||||
'@types/node': 22.19.18
|
||||
'@types/node': 20.19.41
|
||||
iconv-lite: 0.6.3
|
||||
|
||||
'@types/node@22.19.18':
|
||||
'@types/node@20.19.41':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@25.6.2':
|
||||
dependencies:
|
||||
undici-types: 7.19.2
|
||||
|
||||
'@types/nodemailer@8.0.0':
|
||||
dependencies:
|
||||
'@types/node': 22.19.18
|
||||
'@types/node': 20.19.41
|
||||
|
||||
'@types/pdfkit@0.17.6':
|
||||
dependencies:
|
||||
'@types/node': 22.19.18
|
||||
'@types/node': 20.19.41
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
@@ -6573,7 +6563,7 @@ snapshots:
|
||||
|
||||
'@types/readdir-glob@1.1.5':
|
||||
dependencies:
|
||||
'@types/node': 22.19.18
|
||||
'@types/node': 20.19.41
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
@@ -6590,7 +6580,7 @@ snapshots:
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 22.19.18
|
||||
'@types/node': 20.19.41
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.4(jiti@1.21.7))(typescript@6.0.3)':
|
||||
dependencies:
|
||||
@@ -6754,7 +6744,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))
|
||||
vitest: 4.1.5(@types/node@20.19.41)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))
|
||||
|
||||
'@vitest/expect@4.1.5':
|
||||
dependencies:
|
||||
@@ -6765,13 +6755,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.5(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))':
|
||||
'@vitest/mocker@4.1.5(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.5
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)
|
||||
vite: 8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)
|
||||
|
||||
'@vitest/pretty-format@4.1.5':
|
||||
dependencies:
|
||||
@@ -7060,7 +7050,7 @@ snapshots:
|
||||
next: 15.5.18(@playwright/test@1.59.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
vitest: 4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))
|
||||
vitest: 4.1.5(@types/node@20.19.41)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))
|
||||
transitivePeerDependencies:
|
||||
- '@cloudflare/workers-types'
|
||||
- '@opentelemetry/api'
|
||||
@@ -7484,7 +7474,7 @@ snapshots:
|
||||
engine.io@6.6.7:
|
||||
dependencies:
|
||||
'@types/cors': 2.8.19
|
||||
'@types/node': 22.19.18
|
||||
'@types/node': 20.19.41
|
||||
'@types/ws': 8.18.1
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
@@ -9820,8 +9810,6 @@ snapshots:
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.19.2: {}
|
||||
|
||||
unicode-properties@1.4.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
@@ -9915,7 +9903,7 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4):
|
||||
vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
@@ -9923,7 +9911,7 @@ snapshots:
|
||||
rolldown: 1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
'@types/node': 25.6.2
|
||||
'@types/node': 20.19.41
|
||||
esbuild: 0.27.7
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
@@ -9933,10 +9921,10 @@ snapshots:
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
|
||||
vitest@4.1.5(@types/node@25.6.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)):
|
||||
vitest@4.1.5(@types/node@20.19.41)(@vitest/coverage-v8@4.1.5)(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.5
|
||||
'@vitest/mocker': 4.1.5(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))
|
||||
'@vitest/mocker': 4.1.5(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))
|
||||
'@vitest/pretty-format': 4.1.5
|
||||
'@vitest/runner': 4.1.5
|
||||
'@vitest/snapshot': 4.1.5
|
||||
@@ -9953,10 +9941,10 @@ snapshots:
|
||||
tinyexec: 1.1.2
|
||||
tinyglobby: 0.2.16
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.6.2)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)
|
||||
vite: 8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.27.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.6.2
|
||||
'@types/node': 20.19.41
|
||||
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
@@ -84,11 +84,18 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
// ─── Transactional trio creation ────────────────────────────────────────
|
||||
const result = await withTransaction(async (tx) => {
|
||||
// 1. Find or create client by email (case-sensitive contact match, same
|
||||
// behavior as before the refactor).
|
||||
// 1. Find or create client by email. The inquiry-funnel audit
|
||||
// flagged that the previous exact match was case-sensitive —
|
||||
// capital-letter resubmissions spawned duplicate client+yacht+
|
||||
// interest rows. Match LOWER(value) instead so foo@x.com and
|
||||
// Foo@X.COM dedupe to the same client.
|
||||
let clientId: string;
|
||||
const normalizedEmail = data.email.trim().toLowerCase();
|
||||
const existingContact = await tx.query.clientContacts.findFirst({
|
||||
where: and(eq(clientContacts.channel, 'email'), eq(clientContacts.value, data.email)),
|
||||
where: and(
|
||||
eq(clientContacts.channel, 'email'),
|
||||
sql`LOWER(${clientContacts.value}) = ${normalizedEmail}`,
|
||||
),
|
||||
});
|
||||
if (existingContact) {
|
||||
const existingClient = await tx.query.clients.findFirst({
|
||||
@@ -320,7 +327,9 @@ async function createClientInTx(
|
||||
await tx.insert(clientContacts).values({
|
||||
clientId,
|
||||
channel: 'email',
|
||||
value: data.email,
|
||||
// Store lowercased so the case-insensitive dedup match above always
|
||||
// hits on subsequent submissions.
|
||||
value: data.email.trim().toLowerCase(),
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { listAlertsForPort } from '@/lib/services/alerts.service';
|
||||
|
||||
type AlertStatus = 'open' | 'dismissed' | 'resolved';
|
||||
|
||||
export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
// Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent
|
||||
// signals. Gated on admin.view_audit_log — same permission the audit log
|
||||
// page uses.
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;
|
||||
|
||||
@@ -23,4 +27,5 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: filtered });
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { logger } from '@/lib/logger';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
|
||||
import { captureErrorEvent } from '@/lib/services/error-events.service';
|
||||
|
||||
// BR-024: Dedup via signatureHash unique index on documentEvents
|
||||
// Always return 200 from webhook (webhook best practice)
|
||||
@@ -263,6 +264,15 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, event }, 'Error processing Documenso webhook');
|
||||
// The audit caught that webhook handlers were the only API surface
|
||||
// bypassing the platform-error pipeline — admin/errors was silent on
|
||||
// Documenso webhook crashes. Pipe them in so they surface alongside
|
||||
// every other 5xx.
|
||||
void captureErrorEvent({
|
||||
statusCode: 500,
|
||||
error: err,
|
||||
metadata: { source: 'webhook', provider: 'documenso', event },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true }, { status: 200 });
|
||||
|
||||
51
src/lib/db/migrations/0056_audit_hardening.sql
Normal file
51
src/lib/db/migrations/0056_audit_hardening.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- 0056_audit_hardening.sql
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Address several Tier-4/5 audit findings in one migration:
|
||||
--
|
||||
-- 1. user_permission_overrides.user_id had no FK at all (data-model H1).
|
||||
-- Add an explicit reference to user(id) with onDelete='cascade' so a
|
||||
-- deleted user can't leave dangling override rows.
|
||||
--
|
||||
-- 2. user_email_changes lacked a partial unique index on pending rows
|
||||
-- (concurrency H + GDPR follow-up). Without this, a malicious or
|
||||
-- confused admin can spam the email-change endpoint to generate
|
||||
-- multiple pending tokens, each emailing the operator's inbox.
|
||||
--
|
||||
-- 3. user_port_roles.userId previously had no FK either — see data-model
|
||||
-- H1. Add the same cascade.
|
||||
--
|
||||
-- Each statement is wrapped in DO blocks so the migration is replayable
|
||||
-- (idempotent) and tolerant of being run more than once.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_user_permission_overrides_user'
|
||||
AND table_name = 'user_permission_overrides'
|
||||
) THEN
|
||||
ALTER TABLE user_permission_overrides
|
||||
ADD CONSTRAINT fk_user_permission_overrides_user
|
||||
FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_user_port_roles_user'
|
||||
AND table_name = 'user_port_roles'
|
||||
) THEN
|
||||
ALTER TABLE user_port_roles
|
||||
ADD CONSTRAINT fk_user_port_roles_user
|
||||
FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Partial unique index: at most one pending row per user. Pending = both
|
||||
-- `applied_at` and `cancelled_at` are NULL. Lets old / completed rows
|
||||
-- accumulate as history without ever blocking a fresh change.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_email_changes_one_pending
|
||||
ON user_email_changes (user_id)
|
||||
WHERE applied_at IS NULL AND cancelled_at IS NULL;
|
||||
@@ -78,3 +78,42 @@ export function renderShell({ title, body, branding }: ShellOpts): string {
|
||||
export function brandingPrimaryColor(branding?: BrandingShell | null): string {
|
||||
return branding?.primaryColor ?? DEFAULT_PRIMARY_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-safe escaper for `href="..."` interpolations inside email
|
||||
* templates. The email-deliverability audit flagged that every template
|
||||
* inlined `${data.link}` directly into href + visible text without
|
||||
* escaping — a `"` (or worse, a `javascript:` scheme) would break out
|
||||
* of the attribute or trigger an XSS when the recipient opens the email
|
||||
* in a webmail client that runs scripts.
|
||||
*
|
||||
* Two-step defense:
|
||||
* 1. Scheme allow-list — only http(s), mailto, tel survive; everything
|
||||
* else (javascript:, data:, vbscript:, file:, …) is rewritten to
|
||||
* `about:blank`.
|
||||
* 2. HTML-attribute escape on `"`, `<`, `>`, `&`, `'`, backtick.
|
||||
*/
|
||||
export function safeUrl(url: string | null | undefined): string {
|
||||
if (!url) return 'about:blank';
|
||||
const trimmed = String(url).trim();
|
||||
// Block dangerous schemes. The allow-list is intentionally short.
|
||||
const lower = trimmed.toLowerCase();
|
||||
const ok =
|
||||
lower.startsWith('http://') ||
|
||||
lower.startsWith('https://') ||
|
||||
lower.startsWith('mailto:') ||
|
||||
lower.startsWith('tel:') ||
|
||||
// Relative or root-relative paths are also acceptable — they
|
||||
// resolve against the host the email links to (rare in transactional
|
||||
// mail but used by tracking pixels and unsubscribe headers).
|
||||
lower.startsWith('/') ||
|
||||
lower.startsWith('#');
|
||||
if (!ok) return 'about:blank';
|
||||
return trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/`/g, '`');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface AdminEmailChangeData {
|
||||
recipientName?: string;
|
||||
@@ -45,7 +45,7 @@ export function adminEmailChangeEmail(
|
||||
${
|
||||
data.loginUrl
|
||||
? `<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${data.loginUrl}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
<a href="${safeUrl(data.loginUrl)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Sign in
|
||||
</a>
|
||||
</p>`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface InviteData {
|
||||
link: string;
|
||||
@@ -39,13 +39,13 @@ export function crmInviteEmail(
|
||||
link expires in ${data.ttlHours} hours.
|
||||
</p>
|
||||
<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${data.link}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
<a href="${safeUrl(data.link)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Set up your account
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
|
||||
If the button doesn't work, paste this link into your browser:<br />
|
||||
<a href="${data.link}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
||||
<a href="${safeUrl(data.link)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
||||
</p>
|
||||
<p style="font-size:16px; margin-top:30px;">
|
||||
Thank you,<br />
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* transformation from the raw Documenso URL.
|
||||
*/
|
||||
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface RenderOpts {
|
||||
subject?: string | null;
|
||||
@@ -89,13 +89,13 @@ export function signingInvitationEmail(
|
||||
<p style="margin-bottom:18px; font-size:16px; line-height:1.6;">${leadCopy}</p>
|
||||
${customMessageBlock}
|
||||
<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${data.signingUrl}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
<a href="${safeUrl(data.signingUrl)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Review & sign
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px; color:#666; line-height:1.5; padding:14px 0; border-top:1px solid #eee; margin-top:24px;">
|
||||
If the button doesn't work, paste this link into your browser:<br />
|
||||
<a href="${data.signingUrl}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
|
||||
<a href="${safeUrl(data.signingUrl)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
|
||||
</p>
|
||||
<p style="font-size:14px; color:#666; line-height:1.5; margin-top:18px;">
|
||||
Signing happens directly inside our website — your data isn't sent to a third-party signing service.
|
||||
@@ -208,12 +208,12 @@ export function signingReminderEmail(
|
||||
</p>
|
||||
${customMessageBlock}
|
||||
<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${data.signingUrl}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
<a href="${safeUrl(data.signingUrl)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 36px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Sign now
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:13px; color:#666; line-height:1.5; padding:14px 0; border-top:1px solid #eee; margin-top:24px;">
|
||||
Direct link: <a href="${data.signingUrl}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
|
||||
Direct link: <a href="${safeUrl(data.signingUrl)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.signingUrl}</a>
|
||||
</p>
|
||||
<p style="font-size:16px; margin-top:30px;">
|
||||
Thank you,<br />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Used by the notification-digest scheduler (queued in `email` worker).
|
||||
*/
|
||||
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface DigestData {
|
||||
portName: string;
|
||||
@@ -55,7 +55,7 @@ export function notificationDigestEmail(
|
||||
.map((item) => {
|
||||
const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' ');
|
||||
const titleHtml = item.link
|
||||
? `<a href="${item.link}" style="color:${accent}; text-decoration:none;"><strong>${escapeHtml(item.title)}</strong></a>`
|
||||
? `<a href="${safeUrl(item.link)}" style="color:${accent}; text-decoration:none;"><strong>${escapeHtml(item.title)}</strong></a>`
|
||||
: `<strong>${escapeHtml(item.title)}</strong>`;
|
||||
const desc = item.description
|
||||
? `<div style="font-size:13px; color:#666; margin-top:4px;">${escapeHtml(item.description)}</div>`
|
||||
@@ -71,7 +71,7 @@ export function notificationDigestEmail(
|
||||
const tail =
|
||||
data.totalUnread > data.items.length
|
||||
? `<p style="margin-top:14px; font-size:13px; color:#666;">…and ${data.totalUnread - data.items.length} more.
|
||||
<a href="${data.inboxLink}" style="color:${accent};">Open the inbox</a> to see everything.</p>`
|
||||
<a href="${safeUrl(data.inboxLink)}" style="color:${accent};">Open the inbox</a> to see everything.</p>`
|
||||
: '';
|
||||
|
||||
const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface ActivationData {
|
||||
portName: string;
|
||||
@@ -47,13 +47,13 @@ export function activationEmail(
|
||||
The link expires in ${data.ttlHours} hours.
|
||||
</p>
|
||||
<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${data.link}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
<a href="${safeUrl(data.link)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Activate account
|
||||
</a>
|
||||
</p>
|
||||
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
|
||||
If the button doesn't work, paste this link into your browser:<br />
|
||||
<a href="${data.link}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
||||
<a href="${safeUrl(data.link)}" style="color:${accent}; text-decoration:underline; word-break:break-all;">${data.link}</a>
|
||||
</p>
|
||||
<p style="font-size:16px; margin-top:30px;">
|
||||
Thank you,<br />
|
||||
@@ -103,7 +103,7 @@ export function resetEmail(
|
||||
The link expires in ${data.ttlMinutes} minutes.
|
||||
</p>
|
||||
<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${data.link}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
<a href="${safeUrl(data.link)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
|
||||
Reset password
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
|
||||
import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
|
||||
|
||||
interface RenderOpts {
|
||||
branding?: BrandingShell | null;
|
||||
@@ -73,7 +73,7 @@ export function residentialSalesAlert(data: ResidentialSalesAlertData, overrides
|
||||
${data.preferences ? `<tr><td style="color:#666;">Preferences</td><td>${escapeHtml(data.preferences)}</td></tr>` : ''}
|
||||
${data.notes ? `<tr><td style="color:#666;">Notes</td><td>${escapeHtml(data.notes)}</td></tr>` : ''}
|
||||
</table>
|
||||
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${data.crmDeepLink}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''}
|
||||
${data.crmDeepLink ? `<p style="text-align:center; margin:24px 0;"><a href="${safeUrl(data.crmDeepLink)}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:5px; font-weight:bold;">Open in CRM</a></p>` : ''}
|
||||
<p style="font-size:14px; color:#666;">- ${escapeHtml(portName)} CRM</p>`;
|
||||
return {
|
||||
subject,
|
||||
|
||||
176
src/lib/env.ts
176
src/lib/env.ts
@@ -1,102 +1,104 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
// Database
|
||||
DATABASE_URL: z.string().url().startsWith('postgresql://'),
|
||||
const envSchema = z
|
||||
.object({
|
||||
// Database
|
||||
DATABASE_URL: z.string().url().startsWith('postgresql://'),
|
||||
|
||||
// Redis
|
||||
REDIS_URL: z.string().url().startsWith('redis://'),
|
||||
// Redis
|
||||
REDIS_URL: z.string().url().startsWith('redis://'),
|
||||
|
||||
// Auth
|
||||
BETTER_AUTH_SECRET: z.string().min(32),
|
||||
BETTER_AUTH_URL: z.string().url(),
|
||||
CSRF_SECRET: z.string().min(32),
|
||||
// Auth
|
||||
BETTER_AUTH_SECRET: z.string().min(32),
|
||||
BETTER_AUTH_URL: z.string().url(),
|
||||
CSRF_SECRET: z.string().min(32),
|
||||
|
||||
// MinIO
|
||||
MINIO_ENDPOINT: z.string().min(1),
|
||||
MINIO_PORT: z.coerce.number().int().positive(),
|
||||
MINIO_ACCESS_KEY: z.string().min(1),
|
||||
MINIO_SECRET_KEY: z.string().min(1),
|
||||
MINIO_BUCKET: z.string().min(1),
|
||||
MINIO_USE_SSL: z.enum(['true', 'false']).transform((v) => v === 'true'),
|
||||
// MinIO
|
||||
MINIO_ENDPOINT: z.string().min(1),
|
||||
MINIO_PORT: z.coerce.number().int().positive(),
|
||||
MINIO_ACCESS_KEY: z.string().min(1),
|
||||
MINIO_SECRET_KEY: z.string().min(1),
|
||||
MINIO_BUCKET: z.string().min(1),
|
||||
MINIO_USE_SSL: z.enum(['true', 'false']).transform((v) => v === 'true'),
|
||||
|
||||
// Documenso
|
||||
DOCUMENSO_API_URL: z.string().url(),
|
||||
DOCUMENSO_API_KEY: z.string().min(1),
|
||||
DOCUMENSO_API_VERSION: z.enum(['v1', 'v2']).default('v1'),
|
||||
DOCUMENSO_WEBHOOK_SECRET: z.string().min(16),
|
||||
DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().default(8),
|
||||
DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().default(192),
|
||||
DOCUMENSO_DEVELOPER_RECIPIENT_ID: z.coerce.number().int().positive().default(193),
|
||||
DOCUMENSO_APPROVAL_RECIPIENT_ID: z.coerce.number().int().positive().default(194),
|
||||
// Documenso
|
||||
DOCUMENSO_API_URL: z.string().url(),
|
||||
DOCUMENSO_API_KEY: z.string().min(1),
|
||||
DOCUMENSO_API_VERSION: z.enum(['v1', 'v2']).default('v1'),
|
||||
DOCUMENSO_WEBHOOK_SECRET: z.string().min(16),
|
||||
DOCUMENSO_TEMPLATE_ID_EOI: z.coerce.number().int().positive().default(8),
|
||||
DOCUMENSO_CLIENT_RECIPIENT_ID: z.coerce.number().int().positive().default(192),
|
||||
DOCUMENSO_DEVELOPER_RECIPIENT_ID: z.coerce.number().int().positive().default(193),
|
||||
DOCUMENSO_APPROVAL_RECIPIENT_ID: z.coerce.number().int().positive().default(194),
|
||||
|
||||
// Email
|
||||
SMTP_HOST: z.string().min(1),
|
||||
SMTP_PORT: z.coerce.number().int().positive(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASS: z.string().optional(),
|
||||
SMTP_FROM: z.string().optional(),
|
||||
// Dev/test safety net: when set, sendEmail redirects every outbound message
|
||||
// to this address regardless of the requested recipient. Leave empty in prod.
|
||||
EMAIL_REDIRECT_TO: z.string().email().optional(),
|
||||
// Email
|
||||
SMTP_HOST: z.string().min(1),
|
||||
SMTP_PORT: z.coerce.number().int().positive(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASS: z.string().optional(),
|
||||
SMTP_FROM: z.string().optional(),
|
||||
// Dev/test safety net: when set, sendEmail redirects every outbound message
|
||||
// to this address regardless of the requested recipient. Leave empty in prod.
|
||||
EMAIL_REDIRECT_TO: z.string().email().optional(),
|
||||
|
||||
// Encryption
|
||||
EMAIL_CREDENTIAL_KEY: z
|
||||
.string()
|
||||
.length(64)
|
||||
.regex(/^[0-9a-f]+$/i, 'Must be a 64-character hex string'),
|
||||
// Encryption
|
||||
EMAIL_CREDENTIAL_KEY: z
|
||||
.string()
|
||||
.length(64)
|
||||
.regex(/^[0-9a-f]+$/i, 'Must be a 64-character hex string'),
|
||||
|
||||
// Google OAuth (optional)
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
// Google OAuth (optional)
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
|
||||
// Shared secret used by the marketing website's server-side dual-write
|
||||
// helper (POST to /api/public/website-inquiries). Set the SAME value on
|
||||
// the website's CRM_INTAKE_SECRET env. Leave unset in dev/staging until
|
||||
// the website's CRM_INTAKE_URL is also set — without this, the public
|
||||
// intake endpoint refuses every request.
|
||||
WEBSITE_INTAKE_SECRET: z.string().min(16).optional(),
|
||||
// Shared secret used by the marketing website's server-side dual-write
|
||||
// helper (POST to /api/public/website-inquiries). Set the SAME value on
|
||||
// the website's CRM_INTAKE_SECRET env. Leave unset in dev/staging until
|
||||
// the website's CRM_INTAKE_URL is also set — without this, the public
|
||||
// intake endpoint refuses every request.
|
||||
WEBSITE_INTAKE_SECRET: z.string().min(16).optional(),
|
||||
|
||||
// OpenAI (optional)
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
// OpenAI (optional)
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
|
||||
// App
|
||||
APP_URL: z.string().url(),
|
||||
PUBLIC_SITE_URL: z.string().url(),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
/**
|
||||
* HTTP listener port. zod-coerced from PORT so a typo (`PORT=foo`) hard-
|
||||
* fails at boot rather than silently listening on an ephemeral port.
|
||||
*/
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
/**
|
||||
* When true, the filesystem storage backend refuses to start (per
|
||||
* src/lib/storage/filesystem.ts:192). Reading via the zod schema means
|
||||
* a typo on the env var hard-fails at boot rather than silently
|
||||
* disabling the multi-node guard. Per CLAUDE.md, multi-node deploys
|
||||
* MUST use the s3-compatible backend.
|
||||
*/
|
||||
MULTI_NODE_DEPLOYMENT: z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((v) => v === 'true'),
|
||||
}).superRefine((env, ctx) => {
|
||||
// CRITICAL safety net: EMAIL_REDIRECT_TO is a dev/test feature that
|
||||
// silently rewrites every outbound recipient. Leaving it set in prod
|
||||
// funnels every customer email (invites, EOIs, portal magic links,
|
||||
// contracts) to a single inbox. The audit caught this had only a
|
||||
// `logger.debug` line as forensic trail. Refuse boot when both are
|
||||
// simultaneously set in production.
|
||||
if (env.NODE_ENV === 'production' && env.EMAIL_REDIRECT_TO) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['EMAIL_REDIRECT_TO'],
|
||||
message:
|
||||
'EMAIL_REDIRECT_TO must NOT be set in production — it silently rewrites every outbound email recipient. Unset it before deploying.',
|
||||
});
|
||||
}
|
||||
});
|
||||
// App
|
||||
APP_URL: z.string().url(),
|
||||
PUBLIC_SITE_URL: z.string().url(),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
/**
|
||||
* HTTP listener port. zod-coerced from PORT so a typo (`PORT=foo`) hard-
|
||||
* fails at boot rather than silently listening on an ephemeral port.
|
||||
*/
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
/**
|
||||
* When true, the filesystem storage backend refuses to start (per
|
||||
* src/lib/storage/filesystem.ts:192). Reading via the zod schema means
|
||||
* a typo on the env var hard-fails at boot rather than silently
|
||||
* disabling the multi-node guard. Per CLAUDE.md, multi-node deploys
|
||||
* MUST use the s3-compatible backend.
|
||||
*/
|
||||
MULTI_NODE_DEPLOYMENT: z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((v) => v === 'true'),
|
||||
})
|
||||
.superRefine((env, ctx) => {
|
||||
// CRITICAL safety net: EMAIL_REDIRECT_TO is a dev/test feature that
|
||||
// silently rewrites every outbound recipient. Leaving it set in prod
|
||||
// funnels every customer email (invites, EOIs, portal magic links,
|
||||
// contracts) to a single inbox. The audit caught this had only a
|
||||
// `logger.debug` line as forensic trail. Refuse boot when both are
|
||||
// simultaneously set in production.
|
||||
if (env.NODE_ENV === 'production' && env.EMAIL_REDIRECT_TO) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['EMAIL_REDIRECT_TO'],
|
||||
message:
|
||||
'EMAIL_REDIRECT_TO must NOT be set in production — it silently rewrites every outbound email recipient. Unset it before deploying.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
|
||||
@@ -190,6 +190,13 @@ export class S3Backend implements StorageBackend {
|
||||
await withTimeout(
|
||||
this.client.putObject(this.bucket, key, buffer, buffer.length, {
|
||||
'Content-Type': opts.contentType,
|
||||
// Force server-side encryption for every blob — signed contracts,
|
||||
// GDPR exports, pg_dumps, EOI PDFs all otherwise land at rest in
|
||||
// cleartext unless the bucket has default-encryption configured.
|
||||
// The audit's S3-pathing CRITICAL was that this was missing.
|
||||
// SSE-S3 (AES-256) is the right baseline; SSE-KMS can be a future
|
||||
// upgrade for tenants that need their own keys.
|
||||
'x-amz-server-side-encryption': 'AES256',
|
||||
}),
|
||||
STORAGE_DEFAULT_TIMEOUT_MS,
|
||||
`putObject(${key})`,
|
||||
|
||||
Reference in New Issue
Block a user