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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
85 changes: 80 additions & 5 deletions src/__tests__/routes/audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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");
});
});
});
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 @@ -136,4 +136,27 @@ export function marketsCors(): ReturnType<typeof createCorsAllowlistMiddleware>
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<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;
}

export const enforceCors = marketsCors();
5 changes: 5 additions & 0 deletions src/routes/audit.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down
Loading
Loading