chore: ran security check for OWASP top 10
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user