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
|
NEXT_PUBLIC_RUNTIME_REGION=cn
|
||||||
|
|
||||||
# Upstream service endpoints
|
# 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
|
ACCOUNT_SERVICE_URL=https://accounts.svc.plus
|
||||||
NEXT_PUBLIC_ACCOUNT_SERVICE_URL=https://accounts.svc.plus
|
NEXT_PUBLIC_ACCOUNT_SERVICE_URL=https://accounts.svc.plus
|
||||||
SERVER_SERVICE_URL=https://api.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,
|
deriveMaxAgeFromExpires,
|
||||||
MFA_COOKIE_NAME,
|
MFA_COOKIE_NAME,
|
||||||
} from "@lib/authGateway";
|
} from "@lib/authGateway";
|
||||||
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
|
import {
|
||||||
|
getAccountServiceApiBaseUrl,
|
||||||
|
isSelfReferentialServiceTarget,
|
||||||
|
} from "@server/serviceConfig";
|
||||||
|
|
||||||
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
|
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 {
|
try {
|
||||||
const loginBody: Record<string, string> = { email, password };
|
const loginBody: Record<string, string> = { email, password };
|
||||||
if (totpCode) {
|
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 { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
import { MFA_COOKIE_NAME, SESSION_COOKIE_NAME } from '@lib/authGateway'
|
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()
|
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 cookieStore = await cookies()
|
||||||
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? ''
|
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value ?? ''
|
||||||
const storedMfaToken = cookieStore.get(MFA_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
|
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 {
|
function normalizeBrowserBaseUrl(baseUrl: string): string {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return normalizeBaseUrl(baseUrl)
|
return normalizeBaseUrl(baseUrl)
|
||||||
@ -81,7 +94,7 @@ function normalizeBrowserBaseUrl(baseUrl: string): string {
|
|||||||
export function getAccountServiceBaseUrl(): string {
|
export function getAccountServiceBaseUrl(): string {
|
||||||
const configured = readEnvValue('ACCOUNT_SERVICE_URL', 'NEXT_PUBLIC_ACCOUNT_SERVICE_URL')
|
const configured = readEnvValue('ACCOUNT_SERVICE_URL', 'NEXT_PUBLIC_ACCOUNT_SERVICE_URL')
|
||||||
const resolved = configured ?? getRuntimeDefaultAccountServiceUrl()
|
const resolved = configured ?? getRuntimeDefaultAccountServiceUrl()
|
||||||
return normalizeBrowserBaseUrl(resolved)
|
return normalizeServiceOrigin(normalizeBrowserBaseUrl(resolved))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAccountServiceApiBaseUrl(): string {
|
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 {
|
export function getServerServiceBaseUrl(): string {
|
||||||
const configured = readEnvValue(
|
const configured = readEnvValue(
|
||||||
'SERVER_SERVICE_URL',
|
'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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
"server-only": path.resolve(__dirname, "./empty-module.ts"),
|
||||||
"@": path.resolve(__dirname, "..", "..", "src"),
|
"@": path.resolve(__dirname, "..", "..", "src"),
|
||||||
"@components": path.resolve(__dirname, "..", "..", "src", "components"),
|
"@components": path.resolve(__dirname, "..", "..", "src", "components"),
|
||||||
"@i18n": path.resolve(__dirname, "..", "..", "src", "i18n"),
|
"@i18n": path.resolve(__dirname, "..", "..", "src", "i18n"),
|
||||||
|
|||||||
@ -1 +1,4 @@
|
|||||||
import '@testing-library/jest-dom/vitest'
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('server-only', () => ({}))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user