Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/config/env-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
23 changes: 23 additions & 0 deletions src/middleware/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,26 @@ export function marketsCors(): ReturnType<typeof createCorsAllowlistMiddleware>
}

export const enforceCors = marketsCors();

/**
* 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<typeof createCorsAllowlistMiddleware> | null = null;

export function auditCors(): ReturnType<typeof createCorsAllowlistMiddleware> {
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;
}
4 changes: 4 additions & 0 deletions src/routes/audit.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { Router } from "express";
import { accessLog } from "../middleware/accessLog";
import { auditCors } from "../middleware/cors";

export const auditRouter = Router();

// Apply CORS allowlist middleware from env (deny by default; preflight cached)
auditRouter.use(auditCors());

// Apply structured access log middleware to all routes in this router
auditRouter.use(accessLog);

Expand Down
245 changes: 245 additions & 0 deletions tests/auditCors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { describe, it, expect, beforeEach } from "@jest/globals";
import request from "supertest";
import express from "express";
import { createCorsAllowlistMiddleware } from "../src/middleware/cors";

/**
* Lightweight error handler for CORS tests.
* Avoids importing the broken errorHandler.ts which contains a merge conflict.
*/
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 } });
}

/**
* Builds a test app with a cors-protected /api/audit route to verify
* the CORS allowlist behaviour. Uses createCorsAllowlistMiddleware directly
* to simulate what auditCors() does, without requiring the full router
* or DB connections.
*/
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://app.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://app.predictify.dev");

expect(res.status).toBe(200);
expect(res.headers["access-control-allow-origin"]).toBe(
"https://app.predictify.dev",
);
});
});

describe("preflight handling", () => {
let app: express.Express;

beforeEach(() => {
app = makeApp([
"http://localhost:5173",
"https://app.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/events", async () => {
const app = express();
app.use(express.json());

const corsMw = createCorsAllowlistMiddleware({
allowedOrigins: ["http://localhost:5173"],
allowCredentials: true,
});

// Simulate nested router structure: CORS applied on the parent
// and sub-routers inherit it.
const subRouter = express.Router();
subRouter.get("/events", (_req, res) => res.json({ data: [] }));

app.use("/api/audit", corsMw, subRouter);
app.use(testErrorHandler);

const res = await request(app)
.get("/api/audit/events")
.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("/:id", (_req, res) => res.json({ data: {} }));

app.use("/api/audit", corsMw, subRouter);
app.use(testErrorHandler);

const res = await request(app)
.get("/api/audit/some-audit-id")
.set("Origin", "http://localhost:5173");

expect(res.status).toBe(200);
expect(res.headers["access-control-allow-origin"]).toBe(
"http://localhost:5173",
);
});
});
});
61 changes: 61 additions & 0 deletions tests/schema/__snapshots__/rate-limit.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`GET /api/rate-limit — admin listing snapshot should maintain a stable forbidden error shape when unauthenticated 1`] = `
{
"error": {
"code": "forbidden",
},
}
`;

exports[`GET /api/rate-limit — admin listing snapshot should maintain a stable response shape for a successful paginated response 1`] = `
{
"data": [
{
"action": "rate_limit.blocked",
"correlationId": "corr-1",
"createdAt": "2026-07-28T10:00:00.000Z",
"id": "log-1",
"ip": "192.168.1.1",
"rateLimitContext": null,
"walletAddress": "GAHK7EYR7AQ5B56K2RRYUWWC7EJ5CWWWURC2Q4GQRHBDQY7ZLMQVB6TF",
},
],
"nextCursor": "eyJzb3J0VmFsdWUiOiIyMDI2LTA3LTI4VDEwOjAwOjAwLjAwMFoifQ==",
}
`;

exports[`GET /api/rate-limit — admin listing snapshot should maintain a stable validation error shape 1`] = `
{
"error": {
"code": "validation_error",
"message": "limit must be a positive integer",
"requestId": "test-req-id",
},
}
`;

exports[`GET /api/rate-limit/status — public status snapshot should maintain a stable anonymous status shape with zero usage 1`] = `
{
"data": {
"clientIp": "127.0.0.1",
"limit": 60,
"remaining": 60,
"resetAt": Any<String>,
"type": "anonymous",
"used": 0,
"windowMs": 60000,
},
}
`;

exports[`GET /api/rate-limit/status — public status snapshot should maintain a stable authenticated status shape 1`] = `
{
"data": {
"bypasses": true,
"limit": 60,
"type": "authenticated",
"windowMs": 60000,
},
}
`;
Loading
Loading