fix(auth): guard self-referential account proxy

This commit is contained in:
Haitao Pan 2026-03-20 22:45:13 +08:00
parent 08646c0760
commit 4acd8ead70
10 changed files with 321 additions and 3 deletions

View File

@ -17,6 +17,8 @@ NEXT_PUBLIC_RUNTIME_ENVIRONMENT=prod
NEXT_PUBLIC_RUNTIME_REGION=cn
# Upstream service endpoints
# Use root service origins only. Do not point ACCOUNT_SERVICE_URL at console.svc.plus
# and do not include /api/auth or any other path suffix here.
ACCOUNT_SERVICE_URL=https://accounts.svc.plus
NEXT_PUBLIC_ACCOUNT_SERVICE_URL=https://accounts.svc.plus
SERVER_SERVICE_URL=https://api.svc.plus

View File

@ -0,0 +1,89 @@
// @vitest-environment node
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const ORIGINAL_ENV = { ...process.env };
describe("/api/auth/login", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
process.env = { ...ORIGINAL_ENV };
delete process.env.ACCOUNT_SERVICE_URL;
delete process.env.NEXT_PUBLIC_ACCOUNT_SERVICE_URL;
});
afterAll(() => {
vi.unstubAllGlobals();
process.env = ORIGINAL_ENV;
});
it("fails fast when account service points back to console", async () => {
process.env.ACCOUNT_SERVICE_URL = "https://console.svc.plus";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { POST } = await import("./route");
const request = new NextRequest("https://console.svc.plus/api/auth/login", {
method: "POST",
headers: {
"content-type": "application/json",
host: "console.svc.plus",
},
body: JSON.stringify({
email: "admin@svc.plus",
password: "Secret123!",
}),
});
const response = await POST(request);
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
success: false,
error: "account_service_misconfigured",
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("preserves upstream 404 responses", async () => {
process.env.ACCOUNT_SERVICE_URL = "https://accounts.svc.plus";
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "user_not_found" }), {
status: 404,
headers: {
"Content-Type": "application/json",
},
}),
);
vi.stubGlobal("fetch", fetchMock);
const { POST } = await import("./route");
const request = new NextRequest("https://console.svc.plus/api/auth/login", {
method: "POST",
headers: {
"content-type": "application/json",
host: "console.svc.plus",
},
body: JSON.stringify({
email: "admin@svc.plus",
password: "Secret123!",
}),
});
const response = await POST(request);
expect(response.status).toBe(404);
await expect(response.json()).resolves.toMatchObject({
success: false,
error: "user_not_found",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://accounts.svc.plus/api/auth/login",
expect.objectContaining({
method: "POST",
}),
);
});
});

View File

@ -9,7 +9,10 @@ import {
deriveMaxAgeFromExpires,
MFA_COOKIE_NAME,
} from "@lib/authGateway";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import {
getAccountServiceApiBaseUrl,
isSelfReferentialServiceTarget,
} from "@server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
@ -66,6 +69,22 @@ export async function POST(request: NextRequest) {
);
}
if (
isSelfReferentialServiceTarget(
ACCOUNT_API_BASE,
request.headers.get("host"),
)
) {
return NextResponse.json(
{
success: false,
error: "account_service_misconfigured",
needMfa: false,
},
{ status: 502 },
);
}
try {
const loginBody: Record<string, string> = { email, password };
if (totpCode) {

View File

@ -0,0 +1,99 @@
// @vitest-environment node
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const cookiesMock = vi.hoisted(() => vi.fn());
const ORIGINAL_ENV = { ...process.env };
vi.mock("next/headers", () => ({
cookies: cookiesMock,
}));
describe("/api/auth/mfa/status", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
cookiesMock.mockReset();
process.env = { ...ORIGINAL_ENV };
delete process.env.ACCOUNT_SERVICE_URL;
delete process.env.NEXT_PUBLIC_ACCOUNT_SERVICE_URL;
});
afterAll(() => {
vi.unstubAllGlobals();
process.env = ORIGINAL_ENV;
});
it("fails fast when account service points back to console", async () => {
process.env.ACCOUNT_SERVICE_URL = "https://console.svc.plus";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { GET } = await import("./route");
const request = new NextRequest(
"https://console.svc.plus/api/auth/mfa/status?identifier=admin%40svc.plus",
{
headers: {
host: "console.svc.plus",
},
},
);
const response = await GET(request);
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
error: "account_service_misconfigured",
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("forwards identifier and session token to accounts", async () => {
process.env.ACCOUNT_SERVICE_URL = "https://accounts.svc.plus";
cookiesMock.mockResolvedValue({
get(name: string) {
if (name === "xc_session") {
return { value: "session-token" };
}
return undefined;
},
});
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ mfa: { totpEnabled: true } }), {
status: 200,
headers: {
"Content-Type": "application/json",
},
}),
);
vi.stubGlobal("fetch", fetchMock);
const { GET } = await import("./route");
const request = new NextRequest(
"https://console.svc.plus/api/auth/mfa/status?identifier=admin%40svc.plus",
{
headers: {
host: "console.svc.plus",
},
},
);
const response = await GET(request);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
mfa: { totpEnabled: true },
});
expect(fetchMock).toHaveBeenCalledWith(
"https://accounts.svc.plus/api/auth/mfa/status?identifier=admin%40svc.plus",
expect.any(Object),
);
expect(init.headers).toMatchObject({
Accept: "application/json",
Authorization: "Bearer session-token",
});
});
});

View File

@ -2,11 +2,23 @@ import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import { MFA_COOKIE_NAME, SESSION_COOKIE_NAME } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import {
getAccountServiceApiBaseUrl,
isSelfReferentialServiceTarget,
} from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
export async function GET(request: NextRequest) {
if (isSelfReferentialServiceTarget(ACCOUNT_API_BASE, request.headers.get('host'))) {
return NextResponse.json(
{
error: 'account_service_misconfigured',
},
{ status: 502 },
)
}
const cookieStore = await cookies()
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? ''
const storedMfaToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? ''

View File

@ -0,0 +1,49 @@
// @vitest-environment node
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
describe("serviceConfig", () => {
beforeEach(() => {
vi.resetModules();
process.env = { ...ORIGINAL_ENV };
delete process.env.ACCOUNT_SERVICE_URL;
delete process.env.NEXT_PUBLIC_ACCOUNT_SERVICE_URL;
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
it("strips path and query from configured account origins", async () => {
process.env.ACCOUNT_SERVICE_URL =
"https://accounts.svc.plus/api/auth/login?redirect=%2Fpanel#hash";
const serviceConfig = await import("./serviceConfig");
expect(serviceConfig.getAccountServiceBaseUrl()).toBe(
"https://accounts.svc.plus",
);
expect(serviceConfig.getAccountServiceApiBaseUrl()).toBe(
"https://accounts.svc.plus/api/auth",
);
});
it("detects when a service target points back to the current host", async () => {
const serviceConfig = await import("./serviceConfig");
expect(
serviceConfig.isSelfReferentialServiceTarget(
"https://console.svc.plus/api/auth",
"console.svc.plus",
),
).toBe(true);
expect(
serviceConfig.isSelfReferentialServiceTarget(
"http://127.0.0.1:8080/api/auth",
"127.0.0.1:3000",
),
).toBe(false);
});
});

View File

@ -45,6 +45,19 @@ function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl
}
function normalizeServiceOrigin(baseUrl: string): string {
const normalized = normalizeBaseUrl(baseUrl)
try {
const parsed = new URL(normalized)
parsed.pathname = ''
parsed.search = ''
parsed.hash = ''
return normalizeBaseUrl(parsed.toString())
} catch {
return normalized
}
}
function normalizeBrowserBaseUrl(baseUrl: string): string {
if (typeof window === 'undefined') {
return normalizeBaseUrl(baseUrl)
@ -81,7 +94,7 @@ function normalizeBrowserBaseUrl(baseUrl: string): string {
export function getAccountServiceBaseUrl(): string {
const configured = readEnvValue('ACCOUNT_SERVICE_URL', 'NEXT_PUBLIC_ACCOUNT_SERVICE_URL')
const resolved = configured ?? getRuntimeDefaultAccountServiceUrl()
return normalizeBrowserBaseUrl(resolved)
return normalizeServiceOrigin(normalizeBrowserBaseUrl(resolved))
}
export function getAccountServiceApiBaseUrl(): string {
@ -99,6 +112,36 @@ export function getAccountServiceApiBaseUrl(): string {
}
}
function normalizeHostCandidate(value: string): string {
const trimmed = value.trim()
if (!trimmed) {
return ''
}
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
try {
const parsed = new URL(maybeUrl)
return parsed.host.toLowerCase()
} catch {
return trimmed
.replace(/^[^/]+:\/\//, '')
.split('/')[0]
.toLowerCase()
}
}
export function isSelfReferentialServiceTarget(
serviceBaseUrl: string,
requestHost?: string | null,
): boolean {
const normalizedServiceHost = normalizeHostCandidate(serviceBaseUrl)
const normalizedRequestHost = normalizeHostCandidate(requestHost ?? '')
if (!normalizedServiceHost || !normalizedRequestHost) {
return false
}
return normalizedServiceHost === normalizedRequestHost
}
export function getServerServiceBaseUrl(): string {
const configured = readEnvValue(
'SERVER_SERVICE_URL',

View File

@ -0,0 +1 @@
export {};

View File

@ -20,6 +20,7 @@ export default defineConfig({
},
resolve: {
alias: {
"server-only": path.resolve(__dirname, "./empty-module.ts"),
"@": path.resolve(__dirname, "..", "..", "src"),
"@components": path.resolve(__dirname, "..", "..", "src", "components"),
"@i18n": path.resolve(__dirname, "..", "..", "src", "i18n"),

View File

@ -1 +1,4 @@
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
vi.mock('server-only', () => ({}))