Files
stack/apps/api/src/cors.spec.ts
Jason Woltje 617df12b52
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix(SEC-API-25+26): Enable strict ValidationPipe + tighten CORS origin
- Set forbidNonWhitelisted: true in ValidationPipe to reject requests
  with unknown DTO properties, preventing mass assignment vulnerabilities
- Reject requests with no Origin header in production (SEC-API-26)
- Restrict localhost:3001 to development mode only
- Update CORS tests to cover production/development origin validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 15:02:55 -06:00

238 lines
7.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
/**
* CORS Configuration Tests
*
* These tests verify that CORS is configured correctly for cookie-based authentication.
*
* CRITICAL REQUIREMENTS:
* - credentials: true (allows cookies to be sent)
* - origin: must be specific origins, NOT wildcard (security requirement with credentials)
* - Access-Control-Allow-Credentials: true header
* - Access-Control-Allow-Origin: specific origin (not *)
* - No-origin requests blocked in production (SEC-API-26)
*/
/**
* Replicates the CORS origin validation logic from main.ts
* so we can test it in isolation.
*/
function buildOriginValidator(nodeEnv: string | undefined): {
allowedOrigins: string[];
isDevelopment: boolean;
validate: (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void
) => void;
} {
const isDevelopment = nodeEnv !== "production";
const allowedOrigins = [
process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
"https://app.mosaicstack.dev",
"https://api.mosaicstack.dev",
];
if (isDevelopment) {
allowedOrigins.push("http://localhost:3001");
}
const validate = (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void
): void => {
if (!origin) {
if (isDevelopment) {
callback(null, true);
} else {
callback(new Error("CORS: Origin header is required"));
}
return;
}
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
};
return { allowedOrigins, isDevelopment, validate };
}
describe("CORS Configuration", () => {
describe("Configuration requirements", () => {
it("should document required CORS settings for cookie-based auth", () => {
const requiredSettings = {
origin: ["http://localhost:3000", "https://app.mosaicstack.dev"],
credentials: true,
allowedHeaders: ["Content-Type", "Authorization", "Cookie"],
exposedHeaders: ["Set-Cookie"],
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
};
expect(requiredSettings.credentials).toBe(true);
expect(requiredSettings.origin).not.toContain("*");
expect(requiredSettings.allowedHeaders).toContain("Cookie");
});
it("should NOT use wildcard origin with credentials (security violation)", () => {
const validConfig1 = { origin: "*", credentials: false };
const validConfig2 = { origin: "http://localhost:3000", credentials: true };
const invalidConfig = { origin: "*", credentials: true };
expect(validConfig1.origin === "*" && !validConfig1.credentials).toBe(true);
expect(validConfig2.origin !== "*" && validConfig2.credentials).toBe(true);
const isInvalidCombination = invalidConfig.origin === "*" && invalidConfig.credentials;
expect(isInvalidCombination).toBe(true);
});
});
describe("Origin validation", () => {
it("should define allowed origins list", () => {
const { allowedOrigins } = buildOriginValidator("development");
expect(allowedOrigins).toContain("http://localhost:3000");
expect(allowedOrigins).toContain("https://app.mosaicstack.dev");
expect(allowedOrigins).toContain("https://api.mosaicstack.dev");
});
it("should match exact origins, not partial matches", () => {
const origin = "http://localhost:3000";
const maliciousOrigin = "http://localhost:3000.evil.com";
expect(origin).toBe("http://localhost:3000");
expect(maliciousOrigin).not.toBe(origin);
});
it("should support dynamic origin from environment variable", () => {
const defaultOrigin = "http://localhost:3000";
const envOrigin = process.env.NEXT_PUBLIC_APP_URL ?? defaultOrigin;
expect(envOrigin).toBeDefined();
expect(typeof envOrigin).toBe("string");
});
});
describe("Development mode CORS behavior", () => {
it("should allow requests with no origin in development", () => {
const { validate } = buildOriginValidator("development");
return new Promise<void>((resolve) => {
validate(undefined, (err, allow) => {
expect(err).toBeNull();
expect(allow).toBe(true);
resolve();
});
});
});
it("should include localhost:3001 in development origins", () => {
const { allowedOrigins } = buildOriginValidator("development");
expect(allowedOrigins).toContain("http://localhost:3001");
});
it("should allow valid origins in development", () => {
const { validate } = buildOriginValidator("development");
return new Promise<void>((resolve) => {
validate("http://localhost:3000", (err, allow) => {
expect(err).toBeNull();
expect(allow).toBe(true);
resolve();
});
});
});
it("should reject invalid origins in development", () => {
const { validate } = buildOriginValidator("development");
return new Promise<void>((resolve) => {
validate("http://evil.com", (err) => {
expect(err).toBeInstanceOf(Error);
expect(err?.message).toContain("not allowed by CORS");
resolve();
});
});
});
});
describe("Production mode CORS behavior (SEC-API-26)", () => {
it("should reject requests with no origin in production", () => {
const { validate } = buildOriginValidator("production");
return new Promise<void>((resolve) => {
validate(undefined, (err) => {
expect(err).toBeInstanceOf(Error);
expect(err?.message).toBe("CORS: Origin header is required");
resolve();
});
});
});
it("should NOT include localhost:3001 in production origins", () => {
const { allowedOrigins } = buildOriginValidator("production");
expect(allowedOrigins).not.toContain("http://localhost:3001");
});
it("should allow valid production origins", () => {
const { validate } = buildOriginValidator("production");
return new Promise<void>((resolve) => {
validate("https://app.mosaicstack.dev", (err, allow) => {
expect(err).toBeNull();
expect(allow).toBe(true);
resolve();
});
});
});
it("should reject invalid origins in production", () => {
const { validate } = buildOriginValidator("production");
return new Promise<void>((resolve) => {
validate("http://evil.com", (err) => {
expect(err).toBeInstanceOf(Error);
expect(err?.message).toContain("not allowed by CORS");
resolve();
});
});
});
it("should reject malicious origins that try partial matching", () => {
const { validate } = buildOriginValidator("production");
return new Promise<void>((resolve) => {
validate("https://app.mosaicstack.dev.evil.com", (err) => {
expect(err).toBeInstanceOf(Error);
expect(err?.message).toContain("not allowed by CORS");
resolve();
});
});
});
});
describe("ValidationPipe strict mode (SEC-API-25)", () => {
it("should document that forbidNonWhitelisted must be true", () => {
// This verifies the configuration intent:
// forbidNonWhitelisted: true rejects requests with unknown properties
// preventing mass-assignment vulnerabilities
const validationPipeConfig = {
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
transformOptions: {
enableImplicitConversion: false,
},
};
expect(validationPipeConfig.forbidNonWhitelisted).toBe(true);
expect(validationPipeConfig.whitelist).toBe(true);
expect(validationPipeConfig.transformOptions.enableImplicitConversion).toBe(false);
});
});
});