Skip to content

Commit 377603c

Browse files
b-at-neuclaude
andcommitted
#256 address review feedback R1-M1 R1-L1 R1-L2 R1-N1 R1-N2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2c7fd35 commit 377603c

4 files changed

Lines changed: 28 additions & 36 deletions

File tree

app/api/auth/webhook/route.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const webhookEventSchema = z.discriminatedUnion('type', [
4242
magicLinkEventSchema,
4343
]);
4444

45-
const BAD_REQUEST = new Response(null, { status: 400 });
45+
const badRequest = () => new Response(null, { status: 400 });
4646

4747
export async function POST(req: Request): Promise<Response> {
4848
// Read the raw body once — needed both for signature verification and JSON parsing.
@@ -57,18 +57,18 @@ export async function POST(req: Request): Promise<Response> {
5757
signatureHeader,
5858
kidHeader,
5959
);
60-
if (!valid) return BAD_REQUEST;
60+
if (!valid) return badRequest();
6161

6262
// Parse and validate the payload.
6363
let parsed: unknown;
6464
try {
6565
parsed = JSON.parse(rawBody);
6666
} catch {
67-
return BAD_REQUEST;
67+
return badRequest();
6868
}
6969

7070
const result = webhookEventSchema.safeParse(parsed);
71-
if (!result.success) return BAD_REQUEST;
71+
if (!result.success) return badRequest();
7272

7373
const event = result.data;
7474

@@ -95,7 +95,7 @@ export async function POST(req: Request): Promise<Response> {
9595
// TypeScript exhaustiveness guard — unreachable if zod schema is complete.
9696
const _exhaustive: never = event;
9797
void _exhaustive;
98-
return BAD_REQUEST;
98+
return badRequest();
9999
}
100100
}
101101
} catch (err) {

lib/email/templates.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'server-only';
2+
13
// Email templates for Neon Auth webhook events.
24
//
35
// Tailwind classes are ignored by email clients, so inline styles are used here.
@@ -7,6 +9,16 @@
79
// background ~ #ffffff.
810
// Using matching hex values (not Tailwind variables) is intentional for email.
911

12+
// Prevent HTML injection in user-supplied values interpolated into email markup.
13+
function escapeHtml(s: string): string {
14+
return s
15+
.replace(/&/g, '&amp;')
16+
.replace(/</g, '&lt;')
17+
.replace(/>/g, '&gt;')
18+
.replace(/"/g, '&quot;')
19+
.replace(/'/g, '&#39;');
20+
}
21+
1022
interface LayoutOptions {
1123
title: string;
1224
content: string;
@@ -67,6 +79,7 @@ export function otpEmail({
6779
code,
6880
expiresInMinutes,
6981
}: OtpEmailOptions): EmailTemplate {
82+
const safeCode = escapeHtml(code);
7083
const expiryLine = expiresInMinutes
7184
? `This code expires in ${expiresInMinutes} minute${expiresInMinutes === 1 ? '' : 's'}.`
7285
: '';
@@ -75,7 +88,7 @@ export function otpEmail({
7588
<h1 style="margin:0 0 8px;font-size:20px;font-weight:700;color:#09090b;">Your sign-in code</h1>
7689
<p style="margin:0 0 24px;font-size:14px;color:#71717a;">Use the code below to sign in to Aplio.</p>
7790
<div style="background:#f4f4f5;border-radius:6px;padding:20px;text-align:center;margin-bottom:24px;">
78-
<span style="font-size:36px;font-weight:700;letter-spacing:0.15em;color:#09090b;font-family:'Courier New',monospace;">${code}</span>
91+
<span style="font-size:36px;font-weight:700;letter-spacing:0.15em;color:#09090b;font-family:'Courier New',monospace;">${safeCode}</span>
7992
</div>
8093
${expiryLine ? `<p style="margin:0;font-size:13px;color:#71717a;">${expiryLine}</p>` : ''}
8194
`;
@@ -105,6 +118,7 @@ export function magicLinkEmail({
105118
url,
106119
expiresInMinutes,
107120
}: MagicLinkEmailOptions): EmailTemplate {
121+
const safeUrl = escapeHtml(url);
108122
const expiryLine = expiresInMinutes
109123
? `This link expires in ${expiresInMinutes} minute${expiresInMinutes === 1 ? '' : 's'}.`
110124
: '';
@@ -113,10 +127,10 @@ export function magicLinkEmail({
113127
<h1 style="margin:0 0 8px;font-size:20px;font-weight:700;color:#09090b;">Sign in to Aplio</h1>
114128
<p style="margin:0 0 24px;font-size:14px;color:#71717a;">Click the button below to sign in. The link will open your Aplio session.</p>
115129
<div style="text-align:center;margin-bottom:24px;">
116-
<a href="${url}" style="display:inline-block;background:#18181b;color:#ffffff;text-decoration:none;padding:12px 28px;border-radius:6px;font-size:14px;font-weight:600;">Sign in to Aplio</a>
130+
<a href="${safeUrl}" style="display:inline-block;background:#18181b;color:#ffffff;text-decoration:none;padding:12px 28px;border-radius:6px;font-size:14px;font-weight:600;">Sign in to Aplio</a>
117131
</div>
118132
${expiryLine ? `<p style="margin:0;font-size:13px;color:#71717a;">${expiryLine}</p>` : ''}
119-
<p style="margin:16px 0 0;font-size:12px;color:#71717a;">Or copy this link into your browser:<br /><span style="color:#09090b;word-break:break-all;">${url}</span></p>
133+
<p style="margin:16px 0 0;font-size:12px;color:#71717a;">Or copy this link into your browser:<br /><span style="color:#09090b;word-break:break-all;">${safeUrl}</span></p>
120134
`;
121135

122136
const textLines = [

lib/email/verify-webhook.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,13 @@ export async function verifyWebhookSignature(
9494
kidHeader: string | null,
9595
): Promise<VerifyWebhookResult> {
9696
// Skip verification in local dev when the sentinel is set.
97-
if (process.env.NEON_AUTH_WEBHOOK_SECRET === DEV_SENTINEL)
97+
// The production guard prevents this bypass if the sentinel is accidentally
98+
// deployed — NODE_ENV=production is always set by Next.js in a production build.
99+
if (process.env.NEON_AUTH_WEBHOOK_SECRET === DEV_SENTINEL) {
100+
if (process.env.NODE_ENV === 'production')
101+
throw new Error('DEV_SENTINEL cannot be used in production');
98102
return { valid: true };
103+
}
99104

100105
if (!signatureHeader || !kidHeader) return { valid: false };
101106

lib/types.ts

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -230,33 +230,6 @@ export type AdminUserListItem = Prisma.UserGetPayload<{
230230
};
231231
}>;
232232

233-
// Neon Auth webhook event discriminated union — parsed by zod in the webhook route.
234-
// The `data` field shapes match what Neon sends for each event type; expiry fields
235-
// are optional because Neon may omit them for certain configurations.
236-
export type NeonAuthWebhookOtpEvent = {
237-
type: 'send.otp';
238-
data: {
239-
email: string;
240-
otp: string;
241-
expires_at?: string;
242-
expires_in_minutes?: number;
243-
};
244-
};
245-
246-
export type NeonAuthWebhookMagicLinkEvent = {
247-
type: 'send.magic_link';
248-
data: {
249-
email: string;
250-
magic_link: string;
251-
expires_at?: string;
252-
expires_in_minutes?: number;
253-
};
254-
};
255-
256-
export type NeonAuthWebhookEvent =
257-
| NeonAuthWebhookOtpEvent
258-
| NeonAuthWebhookMagicLinkEvent;
259-
260233
// Identity shape passed to nav components so sidebar and mobile nav agree
261234
// on what to display in the user menu.
262235
export interface NavIdentity {

0 commit comments

Comments
 (0)