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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user