diff --git a/.env.example b/.env.example index 00a55526..5ff78aeb 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,10 @@ WEBHOOK_SIGNING_SECRET=replace-with-16+byte-random-string # to the markets endpoint. Leave empty to deny all origins (deny by default). # MARKETS_CORS_ALLOWED_ORIGINS=https://app.predictify.dev,https://staging.predictify.dev +# Comma-separated list of origins allowed to make cross-origin requests +# to the audit endpoint. Leave empty to deny all origins (deny by default). +# AUDIT_CORS_ALLOWED_ORIGINS=https://admin.predictify.dev + # ── Stellar / Soroban ──────────────────────────────────────────────────────── # Network to connect to: testnet | mainnet (default: testnet) diff --git a/src/__tests__/routes/audit.test.ts b/src/__tests__/routes/audit.test.ts index 2681b30d..4efc9bc4 100644 --- a/src/__tests__/routes/audit.test.ts +++ b/src/__tests__/routes/audit.test.ts @@ -17,26 +17,37 @@ describe("auditRouter", () => { beforeEach(() => { app = express(); + // By default the auditRouter reads from env.AUDIT_CORS_ALLOWED_ORIGINS. + // In test env that is "http://localhost:5173,https://admin.predictify.dev", + // so use a matching origin in requests below. app.use("/api/audit", auditRouter); }); describe("GET /api/audit", () => { + const allowedOrigin = "http://localhost:5173"; + it("returns a list of audit events", async () => { - const response = await request(app).get("/api/audit"); + const response = await request(app) + .get("/api/audit") + .set("Origin", allowedOrigin); expect(response.status).toBe(200); expect(response.body).toEqual({ events: [] }); }); it("accepts a valid limit query parameter", async () => { - const response = await request(app).get("/api/audit?limit=5"); + const response = await request(app) + .get("/api/audit?limit=5") + .set("Origin", allowedOrigin); expect(response.status).toBe(200); expect(response.body).toEqual({ events: [] }); }); it("returns 400 if limit is not a number", async () => { - const response = await request(app).get("/api/audit?limit=abc"); + const response = await request(app) + .get("/api/audit?limit=abc") + .set("Origin", allowedOrigin); expect(response.status).toBe(400); expect(response.body).toEqual({ @@ -48,7 +59,9 @@ describe("auditRouter", () => { }); it("returns 400 if limit is less than 1", async () => { - const response = await request(app).get("/api/audit?limit=0"); + const response = await request(app) + .get("/api/audit?limit=0") + .set("Origin", allowedOrigin); expect(response.status).toBe(400); expect(response.body).toEqual({ @@ -60,7 +73,9 @@ describe("auditRouter", () => { }); it("returns 400 if limit is greater than 100", async () => { - const response = await request(app).get("/api/audit?limit=101"); + const response = await request(app) + .get("/api/audit?limit=101") + .set("Origin", allowedOrigin); expect(response.status).toBe(400); expect(response.body).toEqual({ @@ -71,4 +86,64 @@ describe("auditRouter", () => { }); }); }); + + describe("CORS enforcement", () => { + const allowedOrigin = "http://localhost:5173"; + + it("sets Access-Control-Allow-Origin on allowed requests", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", allowedOrigin); + + expect(res.headers["access-control-allow-origin"]).toBe(allowedOrigin); + }); + + it("sets Access-Control-Allow-Credentials on allowed requests", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", allowedOrigin); + + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + }); + + it("denies requests from a disallowed origin", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "https://evil.example.com"); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("forbidden"); + }); + + it("denies requests with no Origin header", async () => { + const res = await request(app).get("/api/audit"); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("forbidden"); + }); + + it("responds with 204 for allowed OPTIONS preflight", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", allowedOrigin); + + expect(res.status).toBe(204); + }); + + it("denies OPTIONS preflight from a disallowed origin", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", "https://evil.example.com"); + + expect(res.status).toBe(403); + }); + + it("sets Access-Control-Max-Age on preflight response", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", allowedOrigin); + + expect(res.headers["access-control-max-age"]).toBe("600"); + }); + }); }); diff --git a/src/config/env-schema.ts b/src/config/env-schema.ts index 702208a5..94ee0dbe 100644 --- a/src/config/env-schema.ts +++ b/src/config/env-schema.ts @@ -50,6 +50,9 @@ const baseSchema = z.object({ // ── Markets CORS ───────────────────────────────────────── MARKETS_CORS_ALLOWED_ORIGINS: z.string().default(""), + // ── Audit CORS ────────────────────────────────────────── + AUDIT_CORS_ALLOWED_ORIGINS: z.string().default(""), + // ── Geo-blocking ────────────────────────────────────────── GEO_BLOCKED_COUNTRIES: z.string().default("").transform((val) => val.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean), diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts index fbb5b074..2a534a3d 100644 --- a/src/middleware/cors.ts +++ b/src/middleware/cors.ts @@ -136,4 +136,27 @@ export function marketsCors(): ReturnType return marketsCorsMiddleware; } +/** + * Pre-configured CORS middleware for the audit endpoint. + * Reads allowed origins from the `AUDIT_CORS_ALLOWED_ORIGINS` env variable. + * When the allowlist is empty, all cross-origin requests to /api/audit are denied. + */ +let auditCorsMiddleware: ReturnType | null = null; + +export function auditCors(): ReturnType { + if (!auditCorsMiddleware) { + const raw = env.AUDIT_CORS_ALLOWED_ORIGINS ?? ""; + const allowedOrigins = raw + .split(",") + .map((o) => o.trim()) + .filter((o) => o.length > 0); + auditCorsMiddleware = createCorsAllowlistMiddleware({ + allowedOrigins, + allowCredentials: true, + maxAgeSeconds: 600, + }); + } + return auditCorsMiddleware; +} + export const enforceCors = marketsCors(); diff --git a/src/routes/audit.ts b/src/routes/audit.ts index 9e78e065..c327a164 100644 --- a/src/routes/audit.ts +++ b/src/routes/audit.ts @@ -1,8 +1,13 @@ import { Router } from "express"; import { accessLog } from "../middleware/accessLog"; +import { auditCors } from "../middleware/cors"; export const auditRouter = Router(); +// Enforce CORS allowlist early so unapproved origins are rejected +// before any processing (preflight responses cached via Access-Control-Max-Age). +auditRouter.use(auditCors()); + // Apply structured access log middleware to all routes in this router auditRouter.use(accessLog); diff --git a/tests/auditCors.test.ts b/tests/auditCors.test.ts new file mode 100644 index 00000000..df3aa007 --- /dev/null +++ b/tests/auditCors.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, beforeEach } from "@jest/globals"; +import request from "supertest"; +import express from "express"; +import { createCorsAllowlistMiddleware } from "../src/middleware/cors"; + +function testErrorHandler( + err: unknown, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, +): void { + const status = (err as { status?: number }).status ?? 500; + const code = (err as { code?: string }).code ?? "internal_error"; + res.status(status).json({ error: { code } }); +} + +function makeApp(allowedOrigins: string[]): express.Express { + const app = express(); + app.use(express.json()); + + const corsMw = createCorsAllowlistMiddleware({ + allowedOrigins, + allowCredentials: true, + maxAgeSeconds: 600, + }); + + app.use("/api/audit", corsMw, (_req, res) => { + res.json({ events: [] }); + }); + + app.use(testErrorHandler); + return app; +} + +describe("Audit CORS allowlist enforcement", () => { + describe("deny-by-default (empty allowlist)", () => { + it("denies all origins when AUDIT_CORS_ALLOWED_ORIGINS is empty", async () => { + const app = makeApp([]); + + const res = await request(app) + .get("/api/audit") + .set("Origin", "https://trusted.example.com"); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("forbidden"); + }); + + it("denies requests with no Origin header when allowlist is empty", async () => { + const app = makeApp([]); + + const res = await request(app).get("/api/audit"); + + expect(res.status).toBe(403); + }); + }); + + describe("origin validation", () => { + let app: express.Express; + + beforeEach(() => { + app = makeApp([ + "http://localhost:5173", + "https://admin.predictify.dev", + ]); + }); + + it("allows requests from an allowed origin", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.status).toBe(200); + }); + + it("sets Access-Control-Allow-Origin header on allowed requests", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.headers["access-control-allow-origin"]).toBe( + "http://localhost:5173", + ); + }); + + it("sets Access-Control-Allow-Credentials on allowed requests", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + }); + + it("denies requests from an origin not in the allowlist", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "https://evil.example.com"); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("forbidden"); + }); + + it("denies requests with no Origin header", async () => { + const res = await request(app).get("/api/audit"); + + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("forbidden"); + }); + + it("includes correlationId in the error envelope", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "https://evil.example.com"); + + expect(res.body.error.correlationId).toBeDefined(); + }); + + it("allows requests from the second configured origin", async () => { + const res = await request(app) + .get("/api/audit") + .set("Origin", "https://admin.predictify.dev"); + + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe( + "https://admin.predictify.dev", + ); + }); + }); + + describe("preflight handling", () => { + let app: express.Express; + + beforeEach(() => { + app = makeApp([ + "http://localhost:5173", + "https://admin.predictify.dev", + ]); + }); + + it("responds with 204 for allowed OPTIONS preflight", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.status).toBe(204); + }); + + it("sets Access-Control-Max-Age on preflight response", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.headers["access-control-max-age"]).toBe("600"); + }); + + it("sets Access-Control-Allow-Methods on preflight", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.headers["access-control-allow-methods"]).toBeDefined(); + }); + + it("sets Access-Control-Allow-Headers on preflight", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", "http://localhost:5173"); + + expect(res.headers["access-control-allow-headers"]).toContain( + "Content-Type", + ); + expect(res.headers["access-control-allow-headers"]).toContain( + "Authorization", + ); + }); + + it("denies preflight from disallowed origin", async () => { + const res = await request(app) + .options("/api/audit") + .set("Origin", "https://evil.example.com"); + + expect(res.status).toBe(403); + }); + }); + + describe("nested routes enforcement", () => { + it("enforces CORS on sub-paths like /api/audit/counts", async () => { + const app = express(); + app.use(express.json()); + + const corsMw = createCorsAllowlistMiddleware({ + allowedOrigins: ["http://localhost:5173"], + allowCredentials: true, + }); + + const subRouter = express.Router(); + subRouter.get("/counts", (_req, res) => res.json({ data: [] })); + + app.use("/api/audit", corsMw, subRouter); + app.use(testErrorHandler); + + const res = await request(app) + .get("/api/audit/counts") + .set("Origin", "https://evil.example.com"); + + expect(res.status).toBe(403); + }); + + it("allows allowed origins on sub-paths", async () => { + const app = express(); + app.use(express.json()); + + const corsMw = createCorsAllowlistMiddleware({ + allowedOrigins: ["http://localhost:5173"], + allowCredentials: true, + }); + + const subRouter = express.Router(); + subRouter.get("/counts", (_req, res) => res.json({ data: [] })); + + app.use("/api/audit", corsMw, subRouter); + app.use(testErrorHandler); + + const res = await request(app) + .get("/api/audit/counts") + .set("Origin", "http://localhost:5173"); + + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe( + "http://localhost:5173", + ); + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index b568ff70..c8d94785 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -7,3 +7,4 @@ process.env.HORIZON_URL = "https://horizon-testnet.stellar.org"; process.env.PREDICTIFY_CONTRACT_ID = "CTEST0000000000000000000000000000000000000000000000000000"; process.env.WEBHOOK_CORS_ALLOWED_ORIGINS = "http://localhost:5173,https://admin.predictify.dev"; process.env.MARKETS_CORS_ALLOWED_ORIGINS = "http://localhost:5173,https://app.predictify.dev"; +process.env.AUDIT_CORS_ALLOWED_ORIGINS = "http://localhost:5173,https://admin.predictify.dev";