chore: ran security check for OWASP top 10
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2026-03-01 20:44:55 -06:00
parent 023587c48c
commit 1645896e54
20 changed files with 1916 additions and 168 deletions

View File

@@ -8,6 +8,33 @@ const BoolFromEnv = z
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),
@@ -18,11 +45,16 @@ const Env = z.object({
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(),
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),
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"),
@@ -51,11 +83,16 @@ const rawEnv = {
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,
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,
APP_ORIGIN: process.env.APP_ORIGIN,
UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION,
UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE,
@@ -92,6 +129,26 @@ if (parsed.NODE_ENV === "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."
);
}
export const env = parsed;