Skip to content
Open
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ agentpay-backend/
stroops, `priceStroops`, `billedStroops`, `/api/v1/billing/*`, and why
`POST /api/v1/settle` drains backend counters without moving funds.

## CORS Configuration

Set `CORS_ALLOWED_ORIGINS` to a comma-separated list of explicit HTTP(S)
origins when browser clients need cross-origin access:

```bash
CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com
```

Origins are matched after trimming whitespace, lowercasing scheme/host, and
removing a trailing slash. Entries with paths, query strings, fragments, or
non-HTTP schemes are ignored. The wildcard `*` is rejected at startup; this API
does not set `Access-Control-Allow-Credentials`, and deployments should list
trusted origins explicitly instead of relying on wildcard reflection.

## Quickstart

Start a local backend on `http://localhost:3001` with the checked-in
Expand Down
94 changes: 94 additions & 0 deletions src/cors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import request from "supertest";
import { createApp } from "./index.js";

function withCorsAllowedOrigins<T>(value: string | undefined, fn: () => T): T {
const previous = process.env.CORS_ALLOWED_ORIGINS;
if (value === undefined) {
delete process.env.CORS_ALLOWED_ORIGINS;
} else {
process.env.CORS_ALLOWED_ORIGINS = value;
}

try {
return fn();
} finally {
if (previous === undefined) {
delete process.env.CORS_ALLOWED_ORIGINS;
} else {
process.env.CORS_ALLOWED_ORIGINS = previous;
}
}
}

void describe("CORS origin allowlist", () => {
void it("normalizes configured origins before matching request origins", async () => {
const app = withCorsAllowedOrigins(" https://APP.example.com/ ", () => createApp());

const res = await request(app)
.options("/api/v1/version")
.set("Origin", "https://app.example.com");

assert.strictEqual(res.status, 204);
assert.strictEqual(
res.headers["access-control-allow-origin"],
"https://app.example.com"
);
assert.strictEqual(res.headers.vary, "Origin");
});

void it("does not reflect unlisted origins", async () => {
const app = withCorsAllowedOrigins("https://app.example.com", () => createApp());

const res = await request(app)
.options("/api/v1/version")
.set("Origin", "https://evil.example.com");

assert.strictEqual(res.status, 204);
assert.strictEqual(res.headers["access-control-allow-origin"], undefined);
assert.strictEqual(res.headers.vary, "Origin");
});

void it("logs and skips malformed allowlist entries while keeping valid entries", async () => {
const warnings: string[] = [];
const previousWarn = console.warn;
console.warn = (message?: unknown) => {
warnings.push(String(message));
};

let app: ReturnType<typeof createApp>;
try {
app = withCorsAllowedOrigins(
"not a url, https://api.example.com/path, https://app.example.com",
() => createApp()
);
} finally {
console.warn = previousWarn;
}

const malformed = await request(app)
.options("/api/v1/version")
.set("Origin", "https://api.example.com");
const valid = await request(app)
.options("/api/v1/version")
.set("Origin", "https://app.example.com");

assert.strictEqual(malformed.headers["access-control-allow-origin"], undefined);
assert.strictEqual(
valid.headers["access-control-allow-origin"],
"https://app.example.com"
);
assert.deepStrictEqual(warnings, [
"Ignoring malformed CORS origin in CORS_ALLOWED_ORIGINS: not a url",
"Ignoring malformed CORS origin in CORS_ALLOWED_ORIGINS: https://api.example.com/path",
]);
});

void it("rejects wildcard allowlist configuration at startup", () => {
assert.throws(
() => withCorsAllowedOrigins("*", () => createApp()),
/CORS_ALLOWED_ORIGINS.*wildcard/i
);
});
});
51 changes: 44 additions & 7 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,16 @@ export function installRequestStateMiddleware(app: Application): void {
* CORS allowlist middleware backed by CORS_ALLOWED_ORIGINS.
*/
function createCorsMiddleware() {
const corsAllowed = (process.env.CORS_ALLOWED_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const corsAllowed = parseCorsOrigins(process.env.CORS_ALLOWED_ORIGINS);

return (req: Request, res: Response, next: NextFunction) => {
const origin = req.header("origin");
if (origin && corsAllowed.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
if (origin) {
res.vary("Origin");
}
const normalizedOrigin = origin ? normalizeCorsOrigin(origin) : undefined;
if (normalizedOrigin && corsAllowed.has(normalizedOrigin)) {
res.setHeader("Access-Control-Allow-Origin", normalizedOrigin);
res.setHeader(
"Access-Control-Allow-Methods",
"GET,POST,PUT,PATCH,DELETE,OPTIONS"
Expand All @@ -68,6 +68,43 @@ function createCorsMiddleware() {
};
}

/**
* Parses configured CORS origins into canonical scheme://host[:port] entries.
*/
function parseCorsOrigins(raw: string | undefined): Set<string> {
const origins = new Set<string>();
for (const entry of (raw ?? "").split(",")) {
const trimmed = entry.trim();
if (!trimmed) continue;
if (trimmed === "*") {
throw new Error(
"CORS_ALLOWED_ORIGINS wildcard '*' is not supported; list explicit http(s) origins"
);
}

const normalized = normalizeCorsOrigin(trimmed);
if (normalized) {
origins.add(normalized);
} else {
console.warn(
`Ignoring malformed CORS origin in CORS_ALLOWED_ORIGINS: ${trimmed}`
);
}
}
return origins;
}

function normalizeCorsOrigin(value: string): string | undefined {
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
if (url.pathname !== "/" || url.search || url.hash) return undefined;
return url.origin.toLowerCase();
} catch {
return undefined;
}
}

/** Adds the minimal hardening headers used by the original app. */
function securityHeadersMiddleware(
_req: Request,
Expand Down