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
50 changes: 48 additions & 2 deletions src/config/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ const baseEnv = {

Object.assign(process.env, baseEnv);

const { envSchema, parseEnv } = await import("./env.js");
const { baseEnvSchema, parseEnv, parseCorsOrigins, resolveCorsOrigins } = await import(
"./env.js"
);

function exampleEnvKeys(): string[] {
return readFileSync(".env.example", "utf8")
Expand Down Expand Up @@ -53,8 +55,52 @@ test("contract IDs must be Soroban contract-shaped strkeys", () => {
);
});

test("CORS_ORIGIN parses a comma-separated list into multiple origins", () => {
assert.deepEqual(parseCorsOrigins("https://a.example, https://b.example"), [
"https://a.example",
"https://b.example",
]);
assert.deepEqual(parseCorsOrigins(undefined), []);
assert.deepEqual(parseCorsOrigins(""), []);
});

test("resolveCorsOrigins falls back to the dev default outside production", () => {
assert.deepEqual(resolveCorsOrigins(undefined, "development"), ["http://localhost:3000"]);
assert.deepEqual(resolveCorsOrigins("https://a.example", "development"), [
"https://a.example",
]);
});

test("production requires CORS_ORIGIN to be set explicitly", () => {
const { CORS_ORIGIN, ...withoutCors } = baseEnv;
assert.throws(
() => parseEnv({ ...withoutCors, NODE_ENV: "production" }),
/CORS_ORIGIN/,
);
});

test('production rejects "*" as a CORS origin', () => {
assert.throws(
() => parseEnv({ ...baseEnv, NODE_ENV: "production", CORS_ORIGIN: "*" }),
/CORS_ORIGIN/,
);
assert.throws(
() => parseEnv({ ...baseEnv, NODE_ENV: "production", CORS_ORIGIN: "https://a.example,*" }),
/CORS_ORIGIN/,
);
});

test("production accepts an explicit, non-wildcard CORS_ORIGIN", () => {
const parsed = parseEnv({
...baseEnv,
NODE_ENV: "production",
CORS_ORIGIN: "https://app.example.com",
});
assert.equal(parsed.CORS_ORIGIN, "https://app.example.com");
});

test(".env.example carries every required schema key", () => {
const schemaKeys = Object.keys(envSchema.shape);
const schemaKeys = Object.keys(baseEnvSchema.shape);
const optionalKeys = new Set(["SIGNING_KMS_KEY_ID", "SIGNING_ENV_SECRET_KEY"]);
const exampleKeys = new Set(exampleEnvKeys());

Expand Down
56 changes: 53 additions & 3 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@ const contractId = z
.string()
.regex(/^C[A-Z2-7]{55}$/, "must be a Soroban contract ID");

export const envSchema = z.object({
const DEV_DEFAULT_ORIGIN = "http://localhost:3000";

const baseEnvSchema = z.object({
NODE_ENV: z.string().default("development"),
PORT: z.coerce.number().default(8080),
API_PREFIX: z.string().default("/api/v1"),
CORS_ORIGIN: z.string().default("http://localhost:3000"),
// No default: a CORS default that's fine in development and silently
// persists into production is how this becomes a real vulnerability.
// Comma-separated so a preview deployment and production can each have
// their own origin(s) at once. Required outright in production —
// enforced below rather than here, since the requirement is conditional
// on NODE_ENV.
CORS_ORIGIN: z.string().optional(),
STELLAR_NETWORK: z.enum(["testnet", "futurenet", "mainnet"]),
SOROBAN_RPC_URL: z.string().url(),
AUDIT_REGISTRY_CONTRACT_ID: contractId,
Expand All @@ -24,6 +32,48 @@ export const envSchema = z.object({
SIGNING_ENV_SECRET_KEY: z.string().optional(),
});

/** Exposed separately so callers that need `.shape` (e.g. tests) can get at
* it — `envSchema` itself is a refined schema and no longer exposes it. */
export { baseEnvSchema };

export const envSchema = baseEnvSchema.superRefine((env, ctx) => {
if (env.NODE_ENV !== "production") return;

if (!env.CORS_ORIGIN || env.CORS_ORIGIN.trim().length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["CORS_ORIGIN"],
message: "CORS_ORIGIN must be set explicitly in production — no default is allowed.",
});
return;
}

if (parseCorsOrigins(env.CORS_ORIGIN).includes("*")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["CORS_ORIGIN"],
message: 'CORS_ORIGIN may not be "*" in production.',
});
}
});

/** Splits a comma-separated origin list into trimmed, non-empty entries. */
export function parseCorsOrigins(raw: string | undefined): string[] {
if (!raw) return [];
return raw
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
}

export function resolveCorsOrigins(raw: string | undefined, nodeEnv: string): string[] {
const parsed = parseCorsOrigins(raw);
// Production requires an explicit, non-wildcard value — already enforced
// by envSchema at parse time, so reaching here with an empty list means
// we're outside production and can fall back to the dev default.
return parsed.length > 0 ? parsed : [DEV_DEFAULT_ORIGIN];
}

export function parseEnv(env: NodeJS.ProcessEnv) {
return envSchema.parse(env);
}
Expand All @@ -34,7 +84,7 @@ export const config = {
nodeEnv: raw.NODE_ENV,
port: raw.PORT,
apiPrefix: raw.API_PREFIX,
corsOrigin: raw.CORS_ORIGIN,
corsOrigin: resolveCorsOrigins(raw.CORS_ORIGIN, raw.NODE_ENV),
stellar: {
network: raw.STELLAR_NETWORK,
sorobanRpcUrl: raw.SOROBAN_RPC_URL,
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ async function buildServer() {

await app.register(cors, {
origin: config.corsOrigin,
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
});

await app.register(healthRoutes);
Expand Down
Loading