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;
|
||||
|
||||
@@ -17,7 +17,6 @@ export type AppConfig = typeof env;
|
||||
|
||||
const openPaths = new Set([
|
||||
"/health",
|
||||
"/health/db",
|
||||
"/auth/login",
|
||||
"/auth/register",
|
||||
"/auth/verify",
|
||||
@@ -31,7 +30,49 @@ const mutationRateLimit = {
|
||||
},
|
||||
},
|
||||
};
|
||||
const authRateLimit = {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 10,
|
||||
timeWindow: 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const codeVerificationRateLimit = {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 8,
|
||||
timeWindow: 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const codeIssueRateLimit = {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 5,
|
||||
timeWindow: 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const pathOf = (url: string) => (url.split("?")[0] || "/");
|
||||
const normalizeClientIp = (ip: string) => ip.replace("::ffff:", "").toLowerCase();
|
||||
const isInternalClientIp = (ip: string) => {
|
||||
const normalized = normalizeClientIp(ip);
|
||||
if (normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost") {
|
||||
return true;
|
||||
}
|
||||
if (normalized.startsWith("10.") || normalized.startsWith("192.168.")) {
|
||||
return true;
|
||||
}
|
||||
const parts = normalized.split(".");
|
||||
if (parts.length === 4 && parts[0] === "172") {
|
||||
const second = Number(parts[1]);
|
||||
if (Number.isFinite(second) && second >= 16 && second <= 31) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const CSRF_COOKIE = "csrf";
|
||||
const CSRF_HEADER = "x-csrf-token";
|
||||
const HASH_OPTIONS: argon2.Options & { raw?: false } = {
|
||||
@@ -58,6 +99,7 @@ export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<Fast
|
||||
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
trustProxy: config.NODE_ENV === "production",
|
||||
requestIdHeader: "x-request-id",
|
||||
genReqId: (req) => {
|
||||
const hdr = req.headers["x-request-id"];
|
||||
@@ -103,8 +145,35 @@ const ensureCsrfCookie = (reply: any, existing?: string) => {
|
||||
const EMAIL_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const DELETE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const EMAIL_TOKEN_COOLDOWN_MS = 60 * 1000; // 60 seconds
|
||||
const PASSWORD_MIN_LENGTH = 12;
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.min(PASSWORD_MIN_LENGTH)
|
||||
.max(128)
|
||||
.regex(/[a-z]/, "Password must include a lowercase letter")
|
||||
.regex(/[A-Z]/, "Password must include an uppercase letter")
|
||||
.regex(/\d/, "Password must include a number")
|
||||
.regex(/[^A-Za-z0-9]/, "Password must include a symbol");
|
||||
|
||||
const normalizeOrigin = (origin: string) => origin.replace(/\/$/, "");
|
||||
const normalizeEmail = (email: string) => email.trim().toLowerCase();
|
||||
const fingerprintEmail = (email: string) =>
|
||||
createHash("sha256").update(normalizeEmail(email)).digest("hex").slice(0, 16);
|
||||
const logSecurityEvent = (
|
||||
req: { id?: string; ip?: string; headers?: Record<string, any>; log: FastifyInstance["log"] },
|
||||
event: string,
|
||||
outcome: "success" | "failure" | "blocked",
|
||||
details?: Record<string, unknown>
|
||||
) => {
|
||||
req.log.warn({
|
||||
securityEvent: event,
|
||||
outcome,
|
||||
requestId: String(req.id ?? ""),
|
||||
ip: (req.ip || "").replace("::ffff:", ""),
|
||||
userAgent: req.headers?.["user-agent"],
|
||||
...details,
|
||||
});
|
||||
};
|
||||
|
||||
const toPlainEmailAddress = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
@@ -118,7 +187,7 @@ const mailer = config.SMTP_HOST
|
||||
host: config.SMTP_HOST,
|
||||
port: Number(config.SMTP_PORT ?? 587),
|
||||
secure: false,
|
||||
requireTLS: true,
|
||||
requireTLS: config.SMTP_REQUIRE_TLS,
|
||||
tls: {
|
||||
rejectUnauthorized: config.SMTP_TLS_REJECT_UNAUTHORIZED,
|
||||
},
|
||||
@@ -128,8 +197,8 @@ const mailer = config.SMTP_HOST
|
||||
pass: config.SMTP_PASS,
|
||||
}
|
||||
: undefined,
|
||||
logger: true,
|
||||
debug: true,
|
||||
logger: !isProd,
|
||||
debug: !isProd,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -162,9 +231,8 @@ async function sendEmail({
|
||||
const finalHtml = buildEmailHtmlBody(html ?? `<p>${text}</p>`);
|
||||
|
||||
if (!mailer) {
|
||||
// Dev fallback: log the email for manual copy
|
||||
app.log.info({ to, subject }, "[email] mailer disabled; logged email content");
|
||||
console.log("[email]", { to, subject, text: finalText });
|
||||
// Avoid exposing one-time codes in logs when SMTP is unavailable.
|
||||
app.log.warn({ to, subject }, "[email] mailer disabled; email not sent");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -226,12 +294,79 @@ async function issueEmailToken(
|
||||
return { code, expiresAt };
|
||||
}
|
||||
|
||||
async function assertEmailTokenCooldown(
|
||||
userId: string,
|
||||
type: "signup" | "delete",
|
||||
cooldownMs: number
|
||||
) {
|
||||
if (cooldownMs <= 0) return;
|
||||
const recent = await app.prisma.emailToken.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type,
|
||||
createdAt: { gte: new Date(Date.now() - cooldownMs) },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
if (!recent) return;
|
||||
const elapsedMs = Date.now() - recent.createdAt.getTime();
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((cooldownMs - elapsedMs) / 1000));
|
||||
const err: any = new Error("Please wait before requesting another code.");
|
||||
err.statusCode = 429;
|
||||
err.code = "EMAIL_TOKEN_COOLDOWN";
|
||||
err.retryAfterSeconds = retryAfterSeconds;
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function clearEmailTokens(userId: string, type?: "signup" | "delete") {
|
||||
await app.prisma.emailToken.deleteMany({
|
||||
where: type ? { userId, type } : { userId },
|
||||
});
|
||||
}
|
||||
|
||||
type LoginAttemptState = {
|
||||
failedAttempts: number;
|
||||
lockedUntilMs: number;
|
||||
};
|
||||
|
||||
const loginAttemptStateByEmail = new Map<string, LoginAttemptState>();
|
||||
|
||||
const getLoginLockout = (email: string) => {
|
||||
const key = normalizeEmail(email);
|
||||
const state = loginAttemptStateByEmail.get(key);
|
||||
if (!state) return { locked: false as const };
|
||||
if (state.lockedUntilMs > Date.now()) {
|
||||
const retryAfterSeconds = Math.max(
|
||||
1,
|
||||
Math.ceil((state.lockedUntilMs - Date.now()) / 1000)
|
||||
);
|
||||
return { locked: true as const, retryAfterSeconds };
|
||||
}
|
||||
if (state.lockedUntilMs > 0) {
|
||||
loginAttemptStateByEmail.delete(key);
|
||||
}
|
||||
return { locked: false as const };
|
||||
};
|
||||
|
||||
const registerFailedLoginAttempt = (email: string) => {
|
||||
const key = normalizeEmail(email);
|
||||
const now = Date.now();
|
||||
const current = loginAttemptStateByEmail.get(key);
|
||||
const failedAttempts = (current?.failedAttempts ?? 0) + 1;
|
||||
if (failedAttempts >= config.AUTH_MAX_FAILED_ATTEMPTS) {
|
||||
const lockedUntilMs = now + config.AUTH_LOCKOUT_WINDOW_MS;
|
||||
loginAttemptStateByEmail.set(key, { failedAttempts: 0, lockedUntilMs });
|
||||
return { locked: true as const, retryAfterSeconds: Math.ceil(config.AUTH_LOCKOUT_WINDOW_MS / 1000) };
|
||||
}
|
||||
loginAttemptStateByEmail.set(key, { failedAttempts, lockedUntilMs: 0 });
|
||||
return { locked: false as const };
|
||||
};
|
||||
|
||||
const clearFailedLoginAttempts = (email: string) => {
|
||||
loginAttemptStateByEmail.delete(normalizeEmail(email));
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the next due date based on frequency for rollover
|
||||
*/
|
||||
@@ -514,7 +649,6 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) {
|
||||
origin: (() => {
|
||||
// Keep local/dev friction-free.
|
||||
if (config.NODE_ENV !== "production") return true;
|
||||
if (configuredOrigins.length === 0) return true;
|
||||
return (origin, cb) => {
|
||||
if (!origin) return cb(null, true);
|
||||
const normalized = normalizeOrigin(origin);
|
||||
@@ -528,17 +662,21 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) {
|
||||
max: config.RATE_LIMIT_MAX,
|
||||
timeWindow: config.RATE_LIMIT_WINDOW_MS,
|
||||
hook: "onRequest",
|
||||
allowList: (req) => {
|
||||
const ip = (req.ip || "").replace("::ffff:", "");
|
||||
return ip === "127.0.0.1" || ip === "::1";
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(fastifyCookie, { secret: config.COOKIE_SECRET });
|
||||
await app.register(fastifyJwt, {
|
||||
secret: config.JWT_SECRET,
|
||||
cookie: { cookieName: "session", signed: false },
|
||||
verify: {
|
||||
algorithms: ["HS256"],
|
||||
allowedIss: config.JWT_ISSUER,
|
||||
allowedAud: config.JWT_AUDIENCE,
|
||||
},
|
||||
sign: {
|
||||
algorithm: "HS256",
|
||||
iss: config.JWT_ISSUER,
|
||||
aud: config.JWT_AUDIENCE,
|
||||
expiresIn: `${config.SESSION_TIMEOUT_MINUTES}m`,
|
||||
},
|
||||
});
|
||||
@@ -584,6 +722,7 @@ app.decorate("ensureUser", async (userId: string) => {
|
||||
if (config.AUTH_DISABLED) {
|
||||
const userIdHeader = req.headers["x-user-id"]?.toString().trim();
|
||||
if (!userIdHeader) {
|
||||
logSecurityEvent(req, "auth.missing_user_id_header", "failure");
|
||||
return reply.code(401).send({ error: "No user ID provided" });
|
||||
}
|
||||
req.userId = userIdHeader;
|
||||
@@ -595,6 +734,10 @@ app.decorate("ensureUser", async (userId: string) => {
|
||||
req.userId = sub;
|
||||
await app.ensureUser(req.userId);
|
||||
} catch {
|
||||
logSecurityEvent(req, "auth.unauthenticated_request", "failure", {
|
||||
path,
|
||||
method: req.method,
|
||||
});
|
||||
return reply
|
||||
.code(401)
|
||||
.send({
|
||||
@@ -618,13 +761,19 @@ app.decorate("ensureUser", async (userId: string) => {
|
||||
const cookieToken = (req.cookies as any)?.[CSRF_COOKIE];
|
||||
const headerToken = typeof req.headers[CSRF_HEADER] === "string" ? req.headers[CSRF_HEADER] : undefined;
|
||||
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
|
||||
logSecurityEvent(req, "csrf.validation", "failure", { path });
|
||||
return reply.code(403).send({ ok: false, code: "CSRF", message: "Invalid CSRF token" });
|
||||
}
|
||||
});
|
||||
|
||||
const AuthBody = z.object({
|
||||
const RegisterBody = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
password: passwordSchema,
|
||||
});
|
||||
|
||||
const LoginBody = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1).max(128),
|
||||
});
|
||||
|
||||
const VerifyBody = z.object({
|
||||
@@ -640,24 +789,21 @@ const AllocationOverrideSchema = z.object({
|
||||
|
||||
app.post(
|
||||
"/auth/register",
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 10,
|
||||
timeWindow: 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
authRateLimit,
|
||||
async (req, reply) => {
|
||||
const parsed = AuthBody.safeParse(req.body);
|
||||
const parsed = RegisterBody.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" });
|
||||
const { email, password } = parsed.data;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
const existing = await app.prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
logSecurityEvent(req, "auth.register", "blocked", {
|
||||
reason: "email_in_use",
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply
|
||||
.code(409)
|
||||
.send({ ok: false, code: "EMAIL_IN_USE", message: "Email already registered" });
|
||||
@@ -683,30 +829,90 @@ app.post(
|
||||
text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`,
|
||||
html: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, you can also verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
|
||||
});
|
||||
logSecurityEvent(req, "auth.register", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return { ok: true, needsVerification: true };
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/auth/login",
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 10,
|
||||
timeWindow: 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
authRateLimit,
|
||||
async (req, reply) => {
|
||||
const parsed = AuthBody.safeParse(req.body);
|
||||
const parsed = LoginBody.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ ok: false, message: "Invalid payload" });
|
||||
const { email, password } = parsed.data;
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
const lockout = getLoginLockout(normalizedEmail);
|
||||
if (lockout.locked) {
|
||||
reply.header("Retry-After", String(lockout.retryAfterSeconds));
|
||||
logSecurityEvent(req, "auth.login", "blocked", {
|
||||
reason: "login_locked",
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
retryAfterSeconds: lockout.retryAfterSeconds,
|
||||
});
|
||||
return reply.code(429).send({
|
||||
ok: false,
|
||||
code: "LOGIN_LOCKED",
|
||||
message: "Too many failed login attempts. Please try again later.",
|
||||
});
|
||||
}
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
where: { email: normalizedEmail },
|
||||
});
|
||||
if (!user?.passwordHash) return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
if (!user?.passwordHash) {
|
||||
const failed = registerFailedLoginAttempt(normalizedEmail);
|
||||
if (failed.locked) {
|
||||
reply.header("Retry-After", String(failed.retryAfterSeconds));
|
||||
logSecurityEvent(req, "auth.login", "blocked", {
|
||||
reason: "login_locked",
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
retryAfterSeconds: failed.retryAfterSeconds,
|
||||
});
|
||||
return reply.code(429).send({
|
||||
ok: false,
|
||||
code: "LOGIN_LOCKED",
|
||||
message: "Too many failed login attempts. Please try again later.",
|
||||
});
|
||||
}
|
||||
logSecurityEvent(req, "auth.login", "failure", {
|
||||
reason: "invalid_credentials",
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
}
|
||||
const valid = await argon2.verify(user.passwordHash, password);
|
||||
if (!valid) return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
if (!valid) {
|
||||
const failed = registerFailedLoginAttempt(normalizedEmail);
|
||||
if (failed.locked) {
|
||||
reply.header("Retry-After", String(failed.retryAfterSeconds));
|
||||
logSecurityEvent(req, "auth.login", "blocked", {
|
||||
reason: "login_locked",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
retryAfterSeconds: failed.retryAfterSeconds,
|
||||
});
|
||||
return reply.code(429).send({
|
||||
ok: false,
|
||||
code: "LOGIN_LOCKED",
|
||||
message: "Too many failed login attempts. Please try again later.",
|
||||
});
|
||||
}
|
||||
logSecurityEvent(req, "auth.login", "failure", {
|
||||
reason: "invalid_credentials",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
}
|
||||
clearFailedLoginAttempts(normalizedEmail);
|
||||
if (!user.emailVerified) {
|
||||
logSecurityEvent(req, "auth.login", "blocked", {
|
||||
reason: "email_not_verified",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(403).send({ ok: false, code: "EMAIL_NOT_VERIFIED", message: "Email not verified" });
|
||||
}
|
||||
await app.ensureUser(user.id);
|
||||
@@ -721,10 +927,14 @@ app.post(
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
ensureCsrfCookie(reply);
|
||||
logSecurityEvent(req, "auth.login", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/auth/logout", async (_req, reply) => {
|
||||
app.post("/auth/logout", async (req, reply) => {
|
||||
reply.clearCookie("session", {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
@@ -732,17 +942,22 @@ app.post("/auth/logout", async (_req, reply) => {
|
||||
secure: config.NODE_ENV === "production",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
logSecurityEvent(req, "auth.logout", "success", { userId: req.userId });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/auth/verify", async (req, reply) => {
|
||||
app.post("/auth/verify", codeVerificationRateLimit, async (req, reply) => {
|
||||
const parsed = VerifyBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ ok: false, message: "Invalid payload" });
|
||||
}
|
||||
const normalizedEmail = parsed.data.email.toLowerCase();
|
||||
const normalizedEmail = normalizeEmail(parsed.data.email);
|
||||
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
if (!user) {
|
||||
logSecurityEvent(req, "auth.verify", "failure", {
|
||||
reason: "invalid_code",
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
|
||||
}
|
||||
const tokenHash = hashToken(parsed.data.code.trim());
|
||||
@@ -750,10 +965,20 @@ app.post("/auth/verify", async (req, reply) => {
|
||||
where: { userId: user.id, type: "signup", tokenHash },
|
||||
});
|
||||
if (!token) {
|
||||
logSecurityEvent(req, "auth.verify", "failure", {
|
||||
reason: "invalid_code",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
|
||||
}
|
||||
if (token.expiresAt < new Date()) {
|
||||
await app.prisma.emailToken.deleteMany({ where: { userId: user.id, type: "signup" } });
|
||||
logSecurityEvent(req, "auth.verify", "failure", {
|
||||
reason: "code_expired",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" });
|
||||
}
|
||||
await app.prisma.user.update({
|
||||
@@ -772,18 +997,51 @@ app.post("/auth/verify", async (req, reply) => {
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
ensureCsrfCookie(reply);
|
||||
logSecurityEvent(req, "auth.verify", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/auth/verify/resend", async (req, reply) => {
|
||||
app.post("/auth/verify/resend", codeIssueRateLimit, async (req, reply) => {
|
||||
const parsed = z.object({ email: z.string().email() }).safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ ok: false, message: "Invalid payload" });
|
||||
}
|
||||
const normalizedEmail = parsed.data.email.toLowerCase();
|
||||
const normalizedEmail = normalizeEmail(parsed.data.email);
|
||||
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
if (!user) return reply.code(200).send({ ok: true });
|
||||
if (user.emailVerified) return { ok: true, alreadyVerified: true };
|
||||
if (!user) {
|
||||
logSecurityEvent(req, "auth.verify_resend", "failure", {
|
||||
reason: "unknown_email",
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(200).send({ ok: true });
|
||||
}
|
||||
if (user.emailVerified) {
|
||||
logSecurityEvent(req, "auth.verify_resend", "blocked", {
|
||||
reason: "already_verified",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return { ok: true, alreadyVerified: true };
|
||||
}
|
||||
try {
|
||||
await assertEmailTokenCooldown(user.id, "signup", EMAIL_TOKEN_COOLDOWN_MS);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "EMAIL_TOKEN_COOLDOWN") {
|
||||
if (typeof err.retryAfterSeconds === "number") {
|
||||
reply.header("Retry-After", String(err.retryAfterSeconds));
|
||||
}
|
||||
logSecurityEvent(req, "auth.verify_resend", "blocked", {
|
||||
reason: "email_token_cooldown",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return reply.code(429).send({ ok: false, code: err.code, message: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await clearEmailTokens(user.id, "signup");
|
||||
const { code } = await issueEmailToken(user.id, "signup", EMAIL_TOKEN_TTL_MS);
|
||||
const origin = normalizeOrigin(config.APP_ORIGIN);
|
||||
@@ -793,10 +1051,14 @@ app.post("/auth/verify/resend", async (req, reply) => {
|
||||
text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`,
|
||||
html: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
|
||||
});
|
||||
logSecurityEvent(req, "auth.verify_resend", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/account/delete-request", async (req, reply) => {
|
||||
app.post("/account/delete-request", codeIssueRateLimit, async (req, reply) => {
|
||||
const Body = z.object({
|
||||
password: z.string().min(1),
|
||||
});
|
||||
@@ -809,12 +1071,37 @@ app.post("/account/delete-request", async (req, reply) => {
|
||||
select: { id: true, email: true, passwordHash: true },
|
||||
});
|
||||
if (!user?.passwordHash) {
|
||||
logSecurityEvent(req, "account.delete_request", "failure", {
|
||||
reason: "invalid_credentials",
|
||||
userId: req.userId,
|
||||
});
|
||||
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
}
|
||||
const valid = await argon2.verify(user.passwordHash, parsed.data.password);
|
||||
if (!valid) {
|
||||
logSecurityEvent(req, "account.delete_request", "failure", {
|
||||
reason: "invalid_credentials",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
}
|
||||
try {
|
||||
await assertEmailTokenCooldown(user.id, "delete", EMAIL_TOKEN_COOLDOWN_MS);
|
||||
} catch (err: any) {
|
||||
if (err?.code === "EMAIL_TOKEN_COOLDOWN") {
|
||||
if (typeof err.retryAfterSeconds === "number") {
|
||||
reply.header("Retry-After", String(err.retryAfterSeconds));
|
||||
}
|
||||
logSecurityEvent(req, "account.delete_request", "blocked", {
|
||||
reason: "email_token_cooldown",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(429).send({ ok: false, code: err.code, message: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await clearEmailTokens(user.id, "delete");
|
||||
const { code } = await issueEmailToken(user.id, "delete", DELETE_TOKEN_TTL_MS);
|
||||
await sendEmail({
|
||||
@@ -823,10 +1110,14 @@ app.post("/account/delete-request", async (req, reply) => {
|
||||
text: `Your SkyMoney delete confirmation code is ${code}. Enter it in the app to delete your account.`,
|
||||
html: `<p>Your SkyMoney delete confirmation code is <strong>${code}</strong>.</p><p>Enter it in the app to delete your account.</p>`,
|
||||
});
|
||||
logSecurityEvent(req, "account.delete_request", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post("/account/confirm-delete", async (req, reply) => {
|
||||
app.post("/account/confirm-delete", codeVerificationRateLimit, async (req, reply) => {
|
||||
const Body = z.object({
|
||||
email: z.string().email(),
|
||||
code: z.string().min(4),
|
||||
@@ -836,16 +1127,38 @@ app.post("/account/confirm-delete", async (req, reply) => {
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ ok: false, message: "Invalid payload" });
|
||||
}
|
||||
const normalizedEmail = parsed.data.email.toLowerCase();
|
||||
const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
const normalizedEmail = normalizeEmail(parsed.data.email);
|
||||
const user = await app.prisma.user.findUnique({ where: { id: req.userId } });
|
||||
if (!user) {
|
||||
logSecurityEvent(req, "account.confirm_delete", "failure", {
|
||||
reason: "user_not_found",
|
||||
userId: req.userId,
|
||||
});
|
||||
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
|
||||
}
|
||||
if (user.email.toLowerCase() !== normalizedEmail) {
|
||||
logSecurityEvent(req, "account.confirm_delete", "blocked", {
|
||||
reason: "email_mismatch",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(403).send({ ok: false, message: "Forbidden" });
|
||||
}
|
||||
if (!user.passwordHash) {
|
||||
logSecurityEvent(req, "account.confirm_delete", "failure", {
|
||||
reason: "invalid_credentials",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
}
|
||||
const passwordOk = await argon2.verify(user.passwordHash, parsed.data.password);
|
||||
if (!passwordOk) {
|
||||
logSecurityEvent(req, "account.confirm_delete", "failure", {
|
||||
reason: "invalid_credentials",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(401).send({ ok: false, message: "Invalid credentials" });
|
||||
}
|
||||
const tokenHash = hashToken(parsed.data.code.trim());
|
||||
@@ -853,10 +1166,20 @@ app.post("/account/confirm-delete", async (req, reply) => {
|
||||
where: { userId: user.id, type: "delete", tokenHash },
|
||||
});
|
||||
if (!token) {
|
||||
logSecurityEvent(req, "account.confirm_delete", "failure", {
|
||||
reason: "invalid_code",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" });
|
||||
}
|
||||
if (token.expiresAt < new Date()) {
|
||||
await clearEmailTokens(user.id, "delete");
|
||||
logSecurityEvent(req, "account.confirm_delete", "failure", {
|
||||
reason: "code_expired",
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" });
|
||||
}
|
||||
await clearEmailTokens(user.id, "delete");
|
||||
@@ -868,6 +1191,10 @@ app.post("/account/confirm-delete", async (req, reply) => {
|
||||
secure: config.NODE_ENV === "production",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
logSecurityEvent(req, "account.confirm_delete", "success", {
|
||||
userId: user.id,
|
||||
emailFingerprint: fingerprintEmail(user.email),
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -962,7 +1289,7 @@ app.patch("/me", async (req, reply) => {
|
||||
app.patch("/me/password", async (req, reply) => {
|
||||
const Body = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8),
|
||||
newPassword: passwordSchema,
|
||||
});
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
@@ -1061,6 +1388,9 @@ app.post("/admin/rollover", async (req, reply) => {
|
||||
if (!config.AUTH_DISABLED) {
|
||||
return reply.code(403).send({ ok: false, message: "Forbidden" });
|
||||
}
|
||||
if (!isInternalClientIp(req.ip || "")) {
|
||||
return reply.code(403).send({ ok: false, message: "Forbidden" });
|
||||
}
|
||||
const Body = z.object({
|
||||
asOf: z.string().datetime().optional(),
|
||||
dryRun: z.boolean().optional(),
|
||||
@@ -1077,13 +1407,15 @@ app.post("/admin/rollover", async (req, reply) => {
|
||||
|
||||
// ----- Health -----
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
app.get("/health/db", async () => {
|
||||
const start = Date.now();
|
||||
const [{ now }] =
|
||||
await app.prisma.$queryRawUnsafe<{ now: Date }[]>("SELECT now() as now");
|
||||
const latencyMs = Date.now() - start;
|
||||
return { ok: true, nowISO: now.toISOString(), latencyMs };
|
||||
});
|
||||
if (config.NODE_ENV !== "production") {
|
||||
app.get("/health/db", async () => {
|
||||
const start = Date.now();
|
||||
const [{ now }] =
|
||||
await app.prisma.$queryRaw<{ now: Date }[]>`SELECT now() as now`;
|
||||
const latencyMs = Date.now() - start;
|
||||
return { ok: true, nowISO: now.toISOString(), latencyMs };
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Dashboard -----
|
||||
app.get("/dashboard", async (req) => {
|
||||
|
||||
Reference in New Issue
Block a user