fix(auth): guard self-referential account proxy
This commit is contained in:
parent
08646c0760
commit
4acd8ead70
@ -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
|
||||
|
||||
89
src/app/api/auth/login/route.test.ts
Normal file
89
src/app/api/auth/login/route.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
99
src/app/api/auth/mfa/status/route.test.ts
Normal file
99
src/app/api/auth/mfa/status/route.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 ?? ''
|
||||
|
||||
49
src/server/serviceConfig.test.ts
Normal file
49
src/server/serviceConfig.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
|
||||
1
tests/unit/empty-module.ts
Normal file
1
tests/unit/empty-module.ts
Normal file
@ -0,0 +1 @@
|
||||
export {};
|
||||
@ -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"),
|
||||
|
||||
@ -1 +1,4 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user