Skip to content

Commit e46ee3a

Browse files
fix: harden auth origin and verification flows
1 parent bbd22bb commit e46ee3a

11 files changed

Lines changed: 267 additions & 31 deletions

File tree

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"next": "16.2.6",
2727
"otpauth": "^9.5.0",
2828
"qrcode": "^1.5.4",
29-
"react": "^19.2.4",
29+
"react": "19.2.4",
3030
"react-dom": "19.2.4",
3131
"recharts": "^3.8.1",
3232
"undici": "^7.25.0"
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const createEmailVerificationRequest = vi.fn();
4+
const getCurrentUserProfile = vi.fn();
5+
const cookiesGet = vi.fn();
6+
7+
vi.mock("next/headers", () => ({
8+
cookies: vi.fn(async () => ({
9+
get: cookiesGet,
10+
})),
11+
}));
12+
13+
vi.mock("@/lib/auth", () => ({
14+
createEmailVerificationRequest,
15+
getCurrentUserProfile,
16+
SESSION_COOKIE_NAME: "diffaudit_session",
17+
}));
18+
19+
describe("email verification route", () => {
20+
beforeEach(() => {
21+
vi.resetModules();
22+
createEmailVerificationRequest.mockReset();
23+
getCurrentUserProfile.mockReset();
24+
cookiesGet.mockReset();
25+
});
26+
27+
it("keeps unauthenticated requests rejected", async () => {
28+
cookiesGet.mockReturnValue(undefined);
29+
const route = await import("./route");
30+
31+
const response = await route.POST();
32+
const payload = await response.json();
33+
34+
expect(response.status).toBe(401);
35+
expect(payload).toEqual({ message: "Unauthorized." });
36+
expect(createEmailVerificationRequest).not.toHaveBeenCalled();
37+
});
38+
39+
it("does not create or return browser-visible verification tokens", async () => {
40+
cookiesGet.mockReturnValue({ value: "session-token" });
41+
getCurrentUserProfile.mockReturnValue({
42+
id: "user-1",
43+
username: "reviewer",
44+
pendingEmail: "reviewer@example.test",
45+
});
46+
const route = await import("./route");
47+
48+
const response = await route.POST();
49+
const payload = await response.json();
50+
51+
expect(response.status).toBe(501);
52+
expect(payload).toEqual({
53+
message: "Email verification is not available.",
54+
code: "email_verification_unavailable",
55+
});
56+
expect(getCurrentUserProfile).toHaveBeenCalledWith("session-token");
57+
expect(createEmailVerificationRequest).not.toHaveBeenCalled();
58+
expect(JSON.stringify(payload)).not.toContain("verificationUrl");
59+
});
60+
});

apps/web/src/app/api/auth/email-verification/route.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { cookies } from "next/headers";
22
import { NextResponse } from "next/server";
33

44
import {
5-
createEmailVerificationRequest,
65
getCurrentUserProfile,
76
SESSION_COOKIE_NAME,
87
} from "@/lib/auth";
@@ -16,16 +15,8 @@ export async function POST() {
1615
return NextResponse.json({ message: "Unauthorized." }, { status: 401 });
1716
}
1817

19-
const platformUrl = process.env.DIFFAUDIT_PLATFORM_URL ?? "http://localhost:3000";
20-
const request = createEmailVerificationRequest(profile.id, platformUrl);
21-
22-
if (!request) {
23-
return NextResponse.json({ message: "No pending email to verify." }, { status: 400 });
24-
}
25-
2618
return NextResponse.json({
27-
ok: true,
28-
email: request.email,
29-
verificationUrl: request.verificationUrl,
30-
});
19+
message: "Email verification is not available.",
20+
code: "email_verification_unavailable",
21+
}, { status: 501 });
3122
}

apps/web/src/app/api/auth/github/callback/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ export async function GET(request: Request) {
7070
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
7171
const platformUrl = resolvePlatformUrl(request);
7272

73+
if (!platformUrl) {
74+
return NextResponse.json({ message: "Platform public URL is not configured." }, { status: 500 });
75+
}
76+
7377
const cookieStore = await cookies();
7478
const storedState = readStoredState(cookieStore.get(STATE_COOKIE)?.value);
7579
cookieStore.delete(STATE_COOKIE);

apps/web/src/app/api/auth/github/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@ export async function GET(request: Request) {
2121
return NextResponse.json({ message: "GitHub OAuth is not configured." }, { status: 500 });
2222
}
2323

24+
if (!platformUrl) {
25+
return NextResponse.json({ message: "Platform public URL is not configured." }, { status: 500 });
26+
}
27+
2428
const state = crypto.randomBytes(16).toString("hex");
2529
const cookieStore = await cookies();
2630
const currentUser = getCurrentUserProfile(cookieStore.get(SESSION_COOKIE_NAME)?.value);
2731
const mode = intent === "connect" && currentUser ? "connect" : "login";
2832

2933
if (intent === "connect" && !currentUser) {
30-
return NextResponse.redirect(new URL(`/login?redirectTo=${encodeURIComponent(redirectTo)}`, request.url));
34+
return NextResponse.redirect(new URL(`/login?redirectTo=${encodeURIComponent(redirectTo)}`, platformUrl));
3135
}
3236

3337
const payload = Buffer.from(JSON.stringify({

apps/web/src/app/api/auth/google/callback/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ export async function GET(request: Request) {
8686
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
8787
const platformUrl = resolvePlatformUrl(request);
8888

89+
if (!platformUrl) {
90+
return NextResponse.json({ message: "Platform public URL is not configured." }, { status: 500 });
91+
}
92+
8993
const cookieStore = await cookies();
9094
const storedState = readStoredState(cookieStore.get(STATE_COOKIE)?.value);
9195
cookieStore.delete(STATE_COOKIE);

apps/web/src/app/api/auth/google/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@ export async function GET(request: Request) {
2121
return NextResponse.json({ message: "Google OAuth is not configured." }, { status: 500 });
2222
}
2323

24+
if (!platformUrl) {
25+
return NextResponse.json({ message: "Platform public URL is not configured." }, { status: 500 });
26+
}
27+
2428
const state = crypto.randomBytes(16).toString("hex");
2529
const cookieStore = await cookies();
2630
const currentUser = getCurrentUserProfile(cookieStore.get(SESSION_COOKIE_NAME)?.value);
2731
const mode = intent === "connect" && currentUser ? "connect" : "login";
2832

2933
if (intent === "connect" && !currentUser) {
30-
return NextResponse.redirect(new URL(`/login?redirectTo=${encodeURIComponent(redirectTo)}`, request.url));
34+
return NextResponse.redirect(new URL(`/login?redirectTo=${encodeURIComponent(redirectTo)}`, platformUrl));
3135
}
3236

3337
const storedState = Buffer.from(JSON.stringify({
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const cookiesMock = vi.fn();
4+
5+
vi.mock("next/headers", () => ({
6+
cookies: cookiesMock,
7+
}));
8+
9+
function hostileRequest(path: string) {
10+
return new Request(`https://internal.invalid${path}`, {
11+
headers: {
12+
host: "attacker.example.test",
13+
"x-forwarded-host": "attacker.example.test",
14+
"x-forwarded-proto": "https",
15+
},
16+
});
17+
}
18+
19+
describe("OAuth public origin", () => {
20+
beforeEach(() => {
21+
vi.resetModules();
22+
cookiesMock.mockReset();
23+
cookiesMock.mockResolvedValue({
24+
get: vi.fn(),
25+
set: vi.fn(),
26+
delete: vi.fn(),
27+
});
28+
vi.stubEnv("NODE_ENV", "production");
29+
process.env.GITHUB_CLIENT_ID = "github-client";
30+
process.env.GITHUB_CLIENT_SECRET = "github-secret";
31+
process.env.GOOGLE_CLIENT_ID = "google-client";
32+
process.env.GOOGLE_CLIENT_SECRET = "google-secret";
33+
delete process.env.DIFFAUDIT_PLATFORM_URL;
34+
});
35+
36+
afterEach(() => {
37+
vi.unstubAllEnvs();
38+
delete process.env.GITHUB_CLIENT_ID;
39+
delete process.env.GITHUB_CLIENT_SECRET;
40+
delete process.env.GOOGLE_CLIENT_ID;
41+
delete process.env.GOOGLE_CLIENT_SECRET;
42+
delete process.env.DIFFAUDIT_PLATFORM_URL;
43+
});
44+
45+
it("fails GitHub OAuth closed instead of deriving redirect_uri from Host headers", async () => {
46+
const route = await import("./github/route");
47+
48+
const response = await route.GET(hostileRequest("/api/auth/github"));
49+
const payload = await response.json();
50+
51+
expect(response.status).toBe(500);
52+
expect(response.headers.get("location")).toBeNull();
53+
expect(payload).toEqual({ message: "Platform public URL is not configured." });
54+
expect(cookiesMock).not.toHaveBeenCalled();
55+
});
56+
57+
it("fails Google OAuth closed instead of deriving redirect_uri from Host headers", async () => {
58+
const route = await import("./google/route");
59+
60+
const response = await route.GET(hostileRequest("/api/auth/google"));
61+
const payload = await response.json();
62+
63+
expect(response.status).toBe(500);
64+
expect(response.headers.get("location")).toBeNull();
65+
expect(payload).toEqual({ message: "Platform public URL is not configured." });
66+
expect(cookiesMock).not.toHaveBeenCalled();
67+
});
68+
69+
it("uses the configured public URL for GitHub OAuth redirect_uri", async () => {
70+
process.env.DIFFAUDIT_PLATFORM_URL = "https://diffaudit.example.test";
71+
const route = await import("./github/route");
72+
73+
const response = await route.GET(hostileRequest("/api/auth/github"));
74+
const location = response.headers.get("location");
75+
const redirect = new URL(location ?? "");
76+
77+
expect(response.status).toBe(307);
78+
expect(redirect.origin).toBe("https://github.com");
79+
expect(redirect.searchParams.get("redirect_uri")).toBe(
80+
"https://diffaudit.example.test/api/auth/github/callback",
81+
);
82+
});
83+
84+
it("uses the configured public URL for unauthenticated connect redirects", async () => {
85+
process.env.DIFFAUDIT_PLATFORM_URL = "https://diffaudit.example.test";
86+
const route = await import("./github/route");
87+
88+
const response = await route.GET(
89+
hostileRequest("/api/auth/github?intent=connect&redirectTo=/workspace/account"),
90+
);
91+
const location = response.headers.get("location");
92+
const redirect = new URL(location ?? "");
93+
94+
expect(response.status).toBe(307);
95+
expect(redirect.origin).toBe("https://diffaudit.example.test");
96+
expect(redirect.pathname).toBe("/login");
97+
expect(redirect.searchParams.get("redirectTo")).toBe("/workspace/account");
98+
});
99+
100+
it("does not redirect callback errors to Host-derived origins", async () => {
101+
const route = await import("./github/callback/route");
102+
103+
const response = await route.GET(hostileRequest("/api/auth/github/callback?error=denied"));
104+
const payload = await response.json();
105+
106+
expect(response.status).toBe(500);
107+
expect(response.headers.get("location")).toBeNull();
108+
expect(payload).toEqual({ message: "Platform public URL is not configured." });
109+
expect(cookiesMock).not.toHaveBeenCalled();
110+
});
111+
});

apps/web/src/lib/auth.test.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from "node:fs";
22
import os from "node:os";
33
import path from "node:path";
44

5-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66

77
import {
88
DEFAULT_REDIRECT_PATH,
@@ -13,6 +13,7 @@ import {
1313
googleOAuthConfigured,
1414
protectedApiPath,
1515
protectedPagePath,
16+
resolveConfiguredPlatformUrl,
1617
resolvePlatformUrl,
1718
sanitizeRedirectPath,
1819
verifyCredentials,
@@ -36,6 +37,7 @@ afterEach(() => {
3637
delete process.env.DIFFAUDIT_SHARED_USERNAME;
3738
delete process.env.DIFFAUDIT_SHARED_PASSWORD;
3839
delete process.env.DIFFAUDIT_PLATFORM_URL;
40+
vi.unstubAllEnvs();
3941
fs.rmSync(tempDir, { recursive: true, force: true });
4042
});
4143

@@ -66,6 +68,19 @@ describe("auth route helpers", () => {
6668
expect(sanitizeRedirectPath("//evil.example/path")).toBe(DEFAULT_REDIRECT_PATH);
6769
});
6870

71+
it("rejects redirect targets that can be normalized into external origins", () => {
72+
expect(sanitizeRedirectPath("/\\\\evil.example/path")).toBe(DEFAULT_REDIRECT_PATH);
73+
expect(sanitizeRedirectPath("/%5C%5Cevil.example/path")).toBe(DEFAULT_REDIRECT_PATH);
74+
expect(sanitizeRedirectPath("/%5c%5cevil.example/path")).toBe(DEFAULT_REDIRECT_PATH);
75+
});
76+
77+
it("rejects redirect targets with whitespace or control characters", () => {
78+
expect(sanitizeRedirectPath(" /workspace")).toBe(DEFAULT_REDIRECT_PATH);
79+
expect(sanitizeRedirectPath("/workspace ")).toBe(DEFAULT_REDIRECT_PATH);
80+
expect(sanitizeRedirectPath("/\t/evil.example")).toBe(DEFAULT_REDIRECT_PATH);
81+
expect(sanitizeRedirectPath("/%09/evil.example")).toBe(DEFAULT_REDIRECT_PATH);
82+
});
83+
6984
it("protects only the workspace routes and not the marketing pages", () => {
7085
expect(protectedPagePath("/")).toBe(false);
7186
expect(protectedPagePath("/trial")).toBe(false);
@@ -95,17 +110,49 @@ describe("auth route helpers", () => {
95110
).toBe(true);
96111
});
97112

98-
it("resolves oauth public URL from the request when configured URL is bind-only", () => {
113+
it("rejects bind-only configured platform URLs in production", () => {
114+
vi.stubEnv("NODE_ENV", "production");
99115
process.env.DIFFAUDIT_PLATFORM_URL = "http://0.0.0.0:3000";
100116

101117
const request = new Request("http://127.0.0.1:3000/api/auth/google", {
102118
headers: {
103-
host: "diffaudit.example.test",
119+
host: "attacker.example.test",
120+
"x-forwarded-host": "attacker.example.test",
104121
"x-forwarded-proto": "https",
105122
},
106123
});
107124

108-
expect(resolvePlatformUrl(request)).toBe("https://diffaudit.example.test");
125+
expect(resolvePlatformUrl(request)).toBeNull();
126+
expect(resolveConfiguredPlatformUrl()).toBeNull();
127+
});
128+
129+
it("rejects unconfigured production platform URLs instead of trusting forwarded hosts", () => {
130+
vi.stubEnv("NODE_ENV", "production");
131+
132+
const request = new Request("https://internal.invalid/api/auth/google", {
133+
headers: {
134+
host: "attacker.example.test",
135+
"x-forwarded-host": "attacker.example.test",
136+
"x-forwarded-proto": "https",
137+
},
138+
});
139+
140+
expect(resolvePlatformUrl(request)).toBeNull();
141+
});
142+
143+
it("keeps localhost origin fallback for local development only", () => {
144+
vi.stubEnv("NODE_ENV", "development");
145+
146+
const request = new Request("http://127.0.0.1:3000/api/auth/google", {
147+
headers: {
148+
host: "attacker.example.test",
149+
"x-forwarded-host": "attacker.example.test",
150+
"x-forwarded-proto": "https",
151+
},
152+
});
153+
154+
expect(resolvePlatformUrl(request)).toBe("http://127.0.0.1:3000");
155+
expect(resolveConfiguredPlatformUrl()).toBe("http://localhost:3000");
109156
});
110157

111158
it("prefers a valid configured public platform URL for oauth redirects", () => {

0 commit comments

Comments
 (0)