added udner construction for file compaction, planning for unbloating
All checks were successful
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 25s

This commit is contained in:
2026-03-15 14:44:47 -05:00
parent 512e21276c
commit ba549f6c84
14 changed files with 663 additions and 31 deletions

View File

@@ -4,7 +4,7 @@ import rateLimit from "@fastify/rate-limit";
import fastifyCookie from "@fastify/cookie";
import fastifyJwt from "@fastify/jwt";
import argon2 from "argon2";
import { randomUUID, createHash, randomInt, randomBytes } from "node:crypto";
import { randomUUID, createHash, randomInt, randomBytes, timingSafeEqual } from "node:crypto";
import nodemailer from "nodemailer";
import { env } from "./env.js";
import { PrismaClient, Prisma } from "@prisma/client";
@@ -17,6 +17,9 @@ export type AppConfig = typeof env;
const openPaths = new Set([
"/health",
"/site-access/status",
"/site-access/unlock",
"/site-access/lock",
"/auth/login",
"/auth/register",
"/auth/verify",
@@ -108,6 +111,8 @@ const isInternalClientIp = (ip: string) => {
};
const CSRF_COOKIE = "csrf";
const CSRF_HEADER = "x-csrf-token";
const SITE_ACCESS_COOKIE = "skymoney_site_access";
const SITE_ACCESS_MAX_AGE_SECONDS = 12 * 60 * 60;
const HASH_OPTIONS: argon2.Options & { raw?: false } = {
type: argon2.argon2id,
memoryCost: 19_456,
@@ -130,6 +135,18 @@ export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<Fast
const isProd = config.NODE_ENV === "production";
const exposeDevVerificationCode = !isProd && config.EMAIL_VERIFY_DEV_CODE_EXPOSE;
const cookieDomain = config.COOKIE_DOMAIN || undefined;
const siteAccessExpectedToken =
config.BREAK_GLASS_VERIFY_CODE && config.BREAK_GLASS_VERIFY_CODE.length >= 32
? createHash("sha256")
.update(`${config.COOKIE_SECRET}:${config.BREAK_GLASS_VERIFY_CODE}`)
.digest("hex")
: null;
const hasSiteAccessBypass = (req: { cookies?: Record<string, unknown> }) => {
const candidate = req.cookies?.[SITE_ACCESS_COOKIE];
if (!siteAccessExpectedToken || typeof candidate !== "string") return false;
return safeEqual(candidate, siteAccessExpectedToken);
};
const app = Fastify({
logger: true,
@@ -310,6 +327,13 @@ function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
function safeEqual(a: string, b: string): boolean {
const left = Buffer.from(a, "utf8");
const right = Buffer.from(b, "utf8");
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
async function issueEmailToken(
userId: string,
type: EmailTokenType,
@@ -754,6 +778,25 @@ app.decorate("ensureUser", async (userId: string) => {
app.addHook("onRequest", async (req, reply) => {
reply.header("x-request-id", String(req.id ?? ""));
const path = pathOf(req.url ?? "");
const isSiteAccessPath =
path === "/site-access/status" ||
path === "/site-access/unlock" ||
path === "/site-access/lock";
if (
config.UNDER_CONSTRUCTION_ENABLED &&
!isSiteAccessPath &&
path !== "/health" &&
path !== "/health/db" &&
!hasSiteAccessBypass(req)
) {
return reply.code(503).send({
ok: false,
code: "UNDER_CONSTRUCTION",
message: "SkyMoney is temporarily under construction.",
requestId: String(req.id ?? ""),
});
}
// Open paths don't require authentication
if (openPaths.has(path)) {
@@ -838,6 +881,8 @@ app.decorate("ensureUser", async (userId: string) => {
return;
}
if (
path === "/site-access/unlock" ||
path === "/site-access/lock" ||
path === "/auth/login" ||
path === "/auth/register" ||
path === "/auth/verify" ||
@@ -1503,6 +1548,65 @@ app.get("/auth/session", async (req, reply) => {
};
});
app.get("/site-access/status", async (req) => {
if (!config.UNDER_CONSTRUCTION_ENABLED) {
return { ok: true, enabled: false, unlocked: true };
}
return {
ok: true,
enabled: true,
unlocked: hasSiteAccessBypass(req),
};
});
app.post("/site-access/unlock", authRateLimit, async (req, reply) => {
const Body = z.object({
code: z.string().min(1).max(512),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ ok: false, code: "INVALID_PAYLOAD", message: "Invalid payload" });
}
if (!config.UNDER_CONSTRUCTION_ENABLED) {
return { ok: true, enabled: false, unlocked: true };
}
if (!config.BREAK_GLASS_VERIFY_ENABLED || !siteAccessExpectedToken) {
return reply.code(503).send({
ok: false,
code: "UNDER_CONSTRUCTION_MISCONFIGURED",
message: "Under-construction access is not configured.",
});
}
if (!safeEqual(parsed.data.code, config.BREAK_GLASS_VERIFY_CODE!)) {
return reply.code(401).send({
ok: false,
code: "INVALID_ACCESS_CODE",
message: "Invalid access code.",
});
}
reply.setCookie(SITE_ACCESS_COOKIE, siteAccessExpectedToken, {
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
path: "/",
maxAge: SITE_ACCESS_MAX_AGE_SECONDS,
...(cookieDomain ? { domain: cookieDomain } : {}),
});
return { ok: true, enabled: true, unlocked: true };
});
app.post("/site-access/lock", mutationRateLimit, async (_req, reply) => {
reply.clearCookie(SITE_ACCESS_COOKIE, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: config.NODE_ENV === "production",
...(cookieDomain ? { domain: cookieDomain } : {}),
});
return { ok: true, enabled: config.UNDER_CONSTRUCTION_ENABLED, unlocked: false };
});
app.post("/app/update-notice/ack", mutationRateLimit, async (req, reply) => {
const Body = z.object({
version: z.coerce.number().int().nonnegative().optional(),