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;

View File

@@ -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) => {