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
3 changes: 2 additions & 1 deletion app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { BackToTopButton } from '@/components/ui/BackToTopButton';
import { LocaleProvider } from '@/components/providers/LocaleProvider';
import { MotionProvider } from '@/components/providers/MotionProvider';
import { getMessages, isValidLocale, locales, type Locale } from '@/lib/i18n';
import { toJsonLd } from '@/lib/json-ld';

type Props = { children: React.ReactNode; params: Promise<{ locale: string }> };

Expand Down Expand Up @@ -76,7 +77,7 @@ export default async function LocaleLayout({ children, params }: Props) {
<MotionProvider>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd) }}
dangerouslySetInnerHTML={{ __html: toJsonLd(personJsonLd) }}
/>
<a
href="#main-content"
Expand Down
125 changes: 116 additions & 9 deletions app/api/contact/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,40 @@ describe('POST /api/contact', () => {
expect(res.status).toBe(200);
});

it('allows Vercel preview deploys matching the project', async () => {
const res = await POST(
makeRequest(validBody, {
Origin: 'https://zachlamb-git-pr-123.vercel.app',
'x-forwarded-for': nextTestIp(),
}),
);
expect(res.status).toBe(200);
it('allows the Vercel preview deploy named by VERCEL_BRANCH_URL', async () => {
vi.stubEnv('VERCEL_BRANCH_URL', 'zachlamb-git-pr-123.vercel.app');
try {
const res = await POST(
makeRequest(validBody, {
Origin: 'https://zachlamb-git-pr-123.vercel.app',
'x-forwarded-for': nextTestIp(),
}),
);
expect(res.status).toBe(200);
} finally {
vi.unstubAllEnvs();
vi.stubEnv('RESEND_API_KEY', 'test_key');
}
});

// Regression: the origin check used to accept any *.vercel.app host whose
// name merely contained "zachlamb". Vercel project names are globally
// claimable, so a third party could deploy "zachlamb-evil" and satisfy the
// CSRF gate. Only exact matches against Vercel's own env vars count now.
it('rejects a third-party vercel.app host that merely contains the project name', async () => {
vi.stubEnv('VERCEL_BRANCH_URL', 'zachlamb-git-pr-123.vercel.app');
try {
const res = await POST(
makeRequest(validBody, {
Origin: 'https://zachlamb-evil.vercel.app',
'x-forwarded-for': nextTestIp(),
}),
);
expect(res.status).toBe(403);
} finally {
vi.unstubAllEnvs();
vi.stubEnv('RESEND_API_KEY', 'test_key');
}
});

it('rejects a non-https Origin that spoofs the vercel.app suffix', async () => {
Expand All @@ -221,6 +247,85 @@ describe('POST /api/contact', () => {
);
expect(res.status).toBe(403);
});

it('rejects a plaintext localhost Origin in production', async () => {
vi.stubEnv('NODE_ENV', 'production');
try {
const res = await POST(
makeRequest(validBody, {
Origin: 'http://localhost:3000',
'x-forwarded-for': nextTestIp(),
}),
);
expect(res.status).toBe(403);
} finally {
vi.unstubAllEnvs();
vi.stubEnv('RESEND_API_KEY', 'test_key');
}
});
});

describe('Body type validation', () => {
const ip = () => ({ 'x-forwarded-for': nextTestIp() });

it.each([
['number', 42],
['object', { toString: 'x' }],
['array', ['Frodo']],
['boolean', true],
['null', null],
])('returns 400 (not 500) when name is a %s', async (_label, value) => {
const res = await POST(
makeRequest({ name: value, email: 'frodo@shire.me', message: 'Hello!' }, ip()),
);
expect(res.status).toBe(400);
});

it('returns 400 when email is a non-string that would coerce past the regex', async () => {
const res = await POST(
makeRequest({ name: 'Frodo', email: ['frodo@shire.me'], message: 'Hello!' }, ip()),
);
expect(res.status).toBe(400);
expect(sendMock).not.toHaveBeenCalled();
});

it('returns 400 when the JSON body is an array', async () => {
const req = new Request('http://localhost/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'http://localhost:3000',
...ip(),
} as HeadersInit,
body: JSON.stringify([{ name: 'Frodo' }]),
});
expect((await POST(req)).status).toBe(400);
});

it('returns 400 when the JSON body is a bare string', async () => {
const req = new Request('http://localhost/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'http://localhost:3000',
...ip(),
} as HeadersInit,
body: JSON.stringify('nope'),
});
expect((await POST(req)).status).toBe(400);
});

it('trims surrounding whitespace rather than rejecting the message', async () => {
const res = await POST(
makeRequest(
{ name: 'Frodo', email: 'frodo@shire.me', message: `${' '.repeat(10)}hello` },
ip(),
),
);
expect(res.status).toBe(200);
const text = sendMock.mock.calls.at(-1)?.[0]?.text as string;
expect(text).toContain('hello');
});
});

describe('Header injection defense', () => {
Expand Down Expand Up @@ -262,7 +367,9 @@ describe('POST /api/contact', () => {
const res = await POST(
makeRequest(
{ name: 'Frodo', email: 'frodo@shire.me', message: 'Hello!' },
{ 'x-forwarded-for': nextTestIp() },
// Must be a production-valid Origin: localhost is only trusted
// outside production now.
{ Origin: 'https://zachlamb.io', 'x-forwarded-for': nextTestIp() },
),
);
expect(res.status).toBe(500);
Expand Down
70 changes: 52 additions & 18 deletions app/api/contact/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,39 @@ const MAX_NAME_LENGTH = 200;
const MAX_EMAIL_LENGTH = 320;
const MAX_MESSAGE_LENGTH = 5000;

const ALLOWED_ORIGINS = new Set<string>([
'https://zachlamb.io',
'https://www.zachlamb.io',
'http://localhost:3000',
'http://localhost',
]);
const ALLOWED_ORIGINS = new Set<string>(['https://zachlamb.io', 'https://www.zachlamb.io']);

// Only trusted outside production, so a prod deploy never accepts a plaintext
// localhost Origin as a same-site request.
const DEV_ALLOWED_ORIGINS = new Set<string>(['http://localhost:3000', 'http://localhost']);

/**
* Origins for the current Vercel deployment, derived from the server-only env
* vars Vercel injects at runtime (no NEXT_PUBLIC_ prefix, so they never reach
* the client bundle).
*
* This replaces a substring heuristic (`hostname.endsWith('.vercel.app') &&
* hostname.includes('zachlamb')`) that any third party could satisfy: Vercel
* project names are globally claimable, so deploying a project named
* `zachlamb-anything` yields `zachlamb-anything.vercel.app` and passed the old
* check — defeating the CSRF origin gate. Exact matching against the values
* Vercel gives us keeps preview deploys working with no wildcard to abuse.
*/
function getVercelOrigins(): string[] {
return [
process.env.VERCEL_PROJECT_PRODUCTION_URL,
process.env.VERCEL_BRANCH_URL,
process.env.VERCEL_URL,
]
.filter((host): host is string => typeof host === 'string' && host.trim() !== '')
.map((host) => `https://${host.trim()}`);
}

function isOriginAllowed(origin: string | null): boolean {
if (!origin) return false;
if (ALLOWED_ORIGINS.has(origin)) return true;
try {
const { hostname, protocol } = new URL(origin);
if (protocol !== 'https:') return false;
// Vercel preview deploys for this project
if (hostname.endsWith('.vercel.app') && hostname.includes('zachlamb')) return true;
} catch {
return false;
}
return false;
if (process.env.NODE_ENV !== 'production' && DEV_ALLOWED_ORIGINS.has(origin)) return true;
return getVercelOrigins().includes(origin);
}

function getClientId(request: Request): string {
Expand All @@ -62,17 +76,37 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}

let body: { name?: string; email?: string; message?: string };
let body: unknown;

try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}

const { name, email, message } = body;
// Parse, don't validate: everything past this point is a known-good string.
// The body is attacker-controlled JSON, so each field must be type-checked
// before any string method touches it — `name.trim()` on a number, array, or
// object throws a TypeError that would escape the handler as an opaque 500.
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}

const { name: rawName, email: rawEmail, message: rawMessage } = body as Record<string, unknown>;

if (
typeof rawName !== 'string' ||
typeof rawEmail !== 'string' ||
typeof rawMessage !== 'string'
) {
return NextResponse.json({ error: 'Name, email, and message are required' }, { status: 400 });
}

const name = rawName.trim();
const email = rawEmail.trim();
const message = rawMessage.trim();

if (!name?.trim() || !email?.trim() || !message?.trim()) {
if (!name || !email || !message) {
return NextResponse.json({ error: 'Name, email, and message are required' }, { status: 400 });
}

Expand Down
73 changes: 57 additions & 16 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -114,43 +114,84 @@ html:lang(it) nav [data-nav-link] {
}

@keyframes contour-drift-a {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(8px, -5px); }
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(8px, -5px);
}
}

@keyframes contour-drift-b {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-6px, 8px); }
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(-6px, 8px);
}
}

@keyframes contour-drift-c {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(10px, 6px); }
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(10px, 6px);
}
}

@keyframes mountain-sway-far {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-1px); }
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-1px);
}
}

@keyframes mountain-sway-near {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-2px);
}
}

@keyframes mountain-sway-tree {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-1.5px); }
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-1.5px);
}
}

@keyframes star-drift {
0%, 100% { transform: translate(0, 0); opacity: var(--star-opacity); }
50% { transform: translate(var(--star-dx), var(--star-dy)); opacity: calc(var(--star-opacity) * 1.5); }
0%,
100% {
transform: translate(0, 0);
opacity: var(--star-opacity);
}
50% {
transform: translate(var(--star-dx), var(--star-dy));
opacity: calc(var(--star-opacity) * 1.5);
}
}

@keyframes endorsement-marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}

@keyframes topo-pulse {
Expand Down
Loading
Loading