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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ operators can pause writes without taking the whole API down.
| `PORT` | `3001` | HTTP port |
| `FEE_BPS` | `10` | Protocol fee in basis points; must be `0`-`10000` or the process fails to start |
| `API_KEY` | – | If set, mutating requests must send `x-api-key` |
| `CORS_ORIGIN` | – | Comma-separated allowlist of origins; unset allows any origin |
| `CORS_ORIGIN` | – | Comma-separated allowlist of HTTP(S) origins, including scheme and optional port; invalid entries fail startup, while unset allows any origin |
| `BODY_LIMIT` | `100kb` | Maximum accepted JSON request body size (`express.json` `limit` syntax) |
| `MAINTENANCE_MODE` | `false` | When `1`/`true`, mutating requests are rejected with `503` |
| `NODE_ENV` | `development` | Environment name |
Expand Down
31 changes: 31 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,37 @@ describe("loadConfig", () => {
expect(loadConfig({ CORS_ORIGIN: " , " }).corsOrigins).toBeUndefined();
});

it.each([
"http://localhost:3000",
"https://api.example.com",
"https://api.example.com:8443",
"https://[2001:db8::1]:8443",
])("accepts the valid CORS origin %p", (origin) => {
expect(loadConfig({ CORS_ORIGIN: origin }).corsOrigins).toEqual([origin]);
});

it.each([
"example.com",
"*",
"ftp://example.com",
"https://user:password@example.com",
"https://example.com/path",
"https://example.com?query=value",
"https://example.com#fragment",
])("rejects the invalid CORS origin %p", (origin) => {
expect(() => loadConfig({ CORS_ORIGIN: origin })).toThrow(
`CORS_ORIGIN contains an invalid origin: ${JSON.stringify(origin)}`,
);
});

it("rejects the full allowlist when one CORS origin is malformed", () => {
expect(() =>
loadConfig({
CORS_ORIGIN: "https://valid.example, example.com",
}),
).toThrow(/example\.com/);
});

it("defaults the JSON body size limit to 100kb", () => {
expect(loadConfig({}).bodyLimit).toBe("100kb");
});
Expand Down
33 changes: 32 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* Typed application configuration loaded from environment variables.
*/

import { URL } from "node:url";

export interface Config {
/** Port the HTTP server binds to. */
port: number;
Expand Down Expand Up @@ -40,7 +42,9 @@ function intFromEnv(value: string | undefined, fallback: number): number {

/**
* Parses a comma-separated `CORS_ORIGIN` value into a list of allowed
* origins, trimming whitespace and dropping empty entries. Returns
* origins, trimming whitespace and dropping empty entries. Only HTTP(S)
* origins are accepted; paths, credentials, queries, and fragments are
* rejected so configuration mistakes fail visibly at startup. Returns
* `undefined` when unset or when every entry is blank.
*/
function parseCorsOrigins(value: string | undefined): string[] | undefined {
Expand All @@ -49,6 +53,33 @@ function parseCorsOrigins(value: string | undefined): string[] | undefined {
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin !== "");

for (const origin of origins) {
let parsed: URL;
try {
parsed = new URL(origin);
} catch {
throw new Error(
`CORS_ORIGIN contains an invalid origin: ${JSON.stringify(origin)}`,
);
}

const isHttpOrigin =
parsed.protocol === "http:" || parsed.protocol === "https:";
const hasOriginOnly =
parsed.username === "" &&
parsed.password === "" &&
parsed.pathname === "/" &&
parsed.search === "" &&
parsed.hash === "";

if (!isHttpOrigin || !hasOriginOnly) {
throw new Error(
`CORS_ORIGIN contains an invalid origin: ${JSON.stringify(origin)}`,
);
}
}

return origins.length > 0 ? origins : undefined;
}

Expand Down