Files
SkyMoney/api/src/env.ts
Ricearoni1245 72334b2583
All checks were successful
Deploy / deploy (push) Successful in 2m2s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 24s
ui fixes, input fixes, better dev workflow
2026-03-10 23:01:44 -05:00

173 lines
7.1 KiB
TypeScript

import { z } from "zod";
const BoolFromEnv = z
.union([z.boolean(), z.string()])
.transform((val) => {
if (typeof val === "boolean") return val;
const normalized = val.trim().toLowerCase();
return normalized === "true" || normalized === "1";
});
function isLoopbackOrPrivateHostname(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
if (!normalized) return true;
if (
normalized === "localhost" ||
normalized === "::1" ||
normalized === "0.0.0.0" ||
normalized.endsWith(".local")
) {
return true;
}
if (normalized.startsWith("127.")) return true;
if (normalized.startsWith("10.")) return true;
if (normalized.startsWith("192.168.")) return true;
if (normalized.startsWith("169.254.")) return true;
const parts = normalized.split(".");
if (parts.length === 4 && parts.every((part) => /^\d+$/.test(part))) {
const octets = parts.map((part) => Number(part));
if (octets.some((n) => n < 0 || n > 255)) return true;
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true;
return false;
}
return false;
}
const Env = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(8080),
HOST: z.string().default("0.0.0.0"),
DATABASE_URL: z.string().min(1),
CORS_ORIGIN: z.string().optional(),
CORS_ORIGINS: z.string().optional(),
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200),
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000),
JWT_SECRET: z.string().min(32),
JWT_ISSUER: z.string().min(1).default("skymoney-api"),
JWT_AUDIENCE: z.string().min(1).default("skymoney-web"),
COOKIE_SECRET: z.string().min(32),
COOKIE_DOMAIN: z.string().optional(),
EMAIL_VERIFY_DEV_CODE_EXPOSE: BoolFromEnv.default(false),
BREAK_GLASS_VERIFY_ENABLED: BoolFromEnv.default(false),
BREAK_GLASS_VERIFY_CODE: z.string().min(32).optional(),
AUTH_DISABLED: BoolFromEnv.optional().default(false),
ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false),
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30),
AUTH_MAX_FAILED_ATTEMPTS: z.coerce.number().int().positive().default(5),
AUTH_LOCKOUT_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60_000),
PASSWORD_RESET_TTL_MINUTES: z.coerce.number().int().positive().default(30),
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(5),
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(10),
APP_ORIGIN: z.string().min(1).default("http://localhost:5173"),
UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0),
UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"),
UPDATE_NOTICE_BODY: z
.string()
.min(1)
.default("We shipped improvements and fixes. Please review the latest changes."),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().int().positive().optional(),
SMTP_REQUIRE_TLS: BoolFromEnv.default(true),
SMTP_TLS_REJECT_UNAUTHORIZED: BoolFromEnv.default(true),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
EMAIL_FROM: z.string().min(1).default("SkyMoney <noreply@skymoneybudget.com>"),
EMAIL_BOUNCE_FROM: z.string().optional(),
EMAIL_REPLY_TO: z.string().optional(),
});
const rawEnv = {
NODE_ENV: process.env.NODE_ENV,
PORT: process.env.PORT,
HOST: process.env.HOST,
DATABASE_URL: process.env.DATABASE_URL,
CORS_ORIGIN: process.env.CORS_ORIGIN,
CORS_ORIGINS: process.env.CORS_ORIGINS,
RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS,
JWT_SECRET: process.env.JWT_SECRET ?? "dev-jwt-secret-change-me",
JWT_ISSUER: process.env.JWT_ISSUER,
JWT_AUDIENCE: process.env.JWT_AUDIENCE,
COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me",
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
EMAIL_VERIFY_DEV_CODE_EXPOSE: process.env.EMAIL_VERIFY_DEV_CODE_EXPOSE,
BREAK_GLASS_VERIFY_ENABLED: process.env.BREAK_GLASS_VERIFY_ENABLED,
BREAK_GLASS_VERIFY_CODE: process.env.BREAK_GLASS_VERIFY_CODE,
AUTH_DISABLED: process.env.AUTH_DISABLED,
ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV,
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES,
AUTH_MAX_FAILED_ATTEMPTS: process.env.AUTH_MAX_FAILED_ATTEMPTS,
AUTH_LOCKOUT_WINDOW_MS: process.env.AUTH_LOCKOUT_WINDOW_MS,
PASSWORD_RESET_TTL_MINUTES: process.env.PASSWORD_RESET_TTL_MINUTES,
PASSWORD_RESET_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_RATE_LIMIT_PER_MINUTE,
PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE: process.env.PASSWORD_RESET_CONFIRM_RATE_LIMIT_PER_MINUTE,
APP_ORIGIN: process.env.APP_ORIGIN,
UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION,
UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE,
UPDATE_NOTICE_BODY: process.env.UPDATE_NOTICE_BODY,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_REQUIRE_TLS: process.env.SMTP_REQUIRE_TLS,
SMTP_TLS_REJECT_UNAUTHORIZED: process.env.SMTP_TLS_REJECT_UNAUTHORIZED,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
EMAIL_FROM: process.env.EMAIL_FROM,
EMAIL_BOUNCE_FROM: process.env.EMAIL_BOUNCE_FROM,
EMAIL_REPLY_TO: process.env.EMAIL_REPLY_TO,
};
const parsed = Env.parse(rawEnv);
if (parsed.NODE_ENV === "production") {
if (!parsed.CORS_ORIGIN && !parsed.CORS_ORIGINS) {
throw new Error("CORS_ORIGIN or CORS_ORIGINS must be set in production.");
}
if (rawEnv.AUTH_DISABLED && parsed.AUTH_DISABLED) {
throw new Error("AUTH_DISABLED cannot be enabled in production.");
}
if (parsed.SEED_DEFAULT_BUDGET) {
throw new Error("SEED_DEFAULT_BUDGET must be disabled in production.");
}
if (parsed.JWT_SECRET.includes("dev-jwt-secret-change-me")) {
throw new Error("JWT_SECRET must be set to a strong value in production.");
}
if (parsed.COOKIE_SECRET.includes("dev-cookie-secret-change-me")) {
throw new Error("COOKIE_SECRET must be set to a strong value in production.");
}
if (!parsed.APP_ORIGIN) {
throw new Error("APP_ORIGIN must be set in production.");
}
if (!parsed.APP_ORIGIN.startsWith("https://")) {
throw new Error("APP_ORIGIN must use https:// in production.");
}
let appOriginUrl: URL;
try {
appOriginUrl = new URL(parsed.APP_ORIGIN);
} catch {
throw new Error("APP_ORIGIN must be a valid URL in production.");
}
if (isLoopbackOrPrivateHostname(appOriginUrl.hostname)) {
throw new Error(
"APP_ORIGIN must not point to localhost or private network hosts in production."
);
}
}
if (parsed.AUTH_DISABLED && parsed.NODE_ENV !== "test" && !parsed.ALLOW_INSECURE_AUTH_FOR_DEV) {
throw new Error(
"AUTH_DISABLED requires ALLOW_INSECURE_AUTH_FOR_DEV=true outside test environments."
);
}
if (parsed.BREAK_GLASS_VERIFY_ENABLED && !parsed.BREAK_GLASS_VERIFY_CODE) {
throw new Error(
"BREAK_GLASS_VERIFY_ENABLED=true requires BREAK_GLASS_VERIFY_CODE (32+ chars)."
);
}
export const env = parsed;