fix(security): authorize site uploads and harden tokens, media and request identity
CI / check (push) Successful in 3m15s
CI / deploy (push) Successful in 1m19s
CI / publish-container (push) Successful in 48s

This commit is contained in:
Simo committed 2026-09-13 19:24:43 +02:00
1 parent 52f6d1491f
commit 8abfe352ef
70 files changed
+1609 -204

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@ import {
isValidVerificationToken,
sendVerification,
verificationToken,
} from "./email-verify";
} from "@/lib/auth/email-verification";
beforeEach(() => {
vi.clearAllMocks();
-125
View File
@@ -1,125 +0,0 @@
"use server";
import { createHmac, timingSafeEqual } from "node:crypto";
import { getTranslations } from "next-intl/server";
import { env } from "@/env";
import { resolveHotelName } from "@/lib/hotel-name";
import { sendMail } from "@/lib/services/email";
// Stateless email verification with a time-limited HMAC token.
//
// Token format: `{issuedAtUnix}.{hmacHex}` where
// hmac = HMAC-SHA256(secret, `${email}|${issuedAt}`)
// Tokens expire after TOKEN_TTL_MS (24h). Legacy forever-valid digests
// (bare 64-char hex) are rejected.
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
/** Secret mixed into the HMAC. Requires at least one of APP_KEY or AUTH_SECRET. */
function verifySecret(): string {
const secret = env.APP_KEY || env.AUTH_SECRET;
if (!secret)
throw new Error(
"APP_KEY or AUTH_SECRET must be set for email verification",
);
return secret;
}
function sign(email: string, issuedAt: number): string {
return createHmac("sha256", verifySecret())
.update(`${email}|${issuedAt}`)
.digest("hex");
}
/** Compute a fresh verification token for an email (lowercased + trimmed). */
export async function verificationToken(email: string): Promise<string> {
const normalised = email.trim().toLowerCase();
const issuedAt = Math.floor(Date.now() / 1000);
return `${issuedAt}.${sign(normalised, issuedAt)}`;
}
/**
* Constant-time check that `token` matches a non-expired HMAC for `email`.
* Returns false on format/expiry/signature mismatch rather than throwing.
*/
export async function isValidVerificationToken(
email: string,
token: string,
): Promise<boolean> {
if (!email || !token) return false;
const normalised = email.trim().toLowerCase();
const match = /^(\d+)\.([a-f0-9]{64})$/i.exec(token.trim());
if (!match) return false; // also rejects legacy forever-valid digests
const issuedAt = Number(match[1]);
const sig = match[2]?.toLowerCase() ?? "";
if (!Number.isFinite(issuedAt) || issuedAt <= 0) return false;
const ageMs = Date.now() - issuedAt * 1000;
if (ageMs < 0 || ageMs > TOKEN_TTL_MS) return false;
const expected = sign(normalised, issuedAt);
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(sig, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Build the verification link + email and send it. No-ops gracefully when SMTP
* is unconfigured (sendMail returns false).
*/
export async function sendVerification(email: string): Promise<boolean> {
const normalised = email.trim().toLowerCase();
if (!normalised) return false;
const token = await verificationToken(normalised);
const base = env.APP_URL.replace(/\/+$/, "");
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
const hotelName = await resolveHotelName();
let subject = `Verify your email · ${hotelName}`;
let heading = "Verify your email";
let body = `Welcome to ${hotelName}! Confirm this email address to finish setting up your account.`;
let button = "Verify email";
let fallback =
"If the button doesn't work, paste this link into your browser:";
try {
const t = await getTranslations("emails.verify");
subject = t("subject", { hotel: hotelName });
heading = t("heading");
body = t("body", { hotel: hotelName });
button = t("button");
fallback = t("fallback");
} catch {
/* messages missing — keep English defaults */
}
const html = `
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
<h2 style="margin:0 0 0.5rem">${escapeHtml(heading)}</h2>
<p>${escapeHtml(body)}</p>
<p style="margin:1.25rem 0">
<a href="${link}"
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
${escapeHtml(button)}
</a>
</p>
<p style="color:#64748b;font-size:0.875rem">${escapeHtml(fallback)}</p>
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">${link}</p>
</div>
`.trim();
return sendMail(normalised, subject, html);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
+1 -1
View File
@@ -3,7 +3,7 @@
import { count, eq } from "drizzle-orm";
import { after } from "next/server";
import { z } from "zod";
import { sendVerification } from "@/actions/email-verify";
import { sendVerification } from "@/lib/auth/email-verification";
import { hashPassword } from "@/lib/auth/password";
import { invalidateKey } from "@/lib/cached-db";
import { db, User } from "@/lib/db";
+21 -44
View File
@@ -4,53 +4,33 @@ import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { validateSiteImageUpload } from "@/lib/images/site-image-upload";
import { logger } from "@/lib/logger";
import { resolveMediaPath } from "@/lib/media-storage";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
const FAVICON_DIR = resolveMediaPath("favicon");
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
const ALLOWED = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/x-icon",
"image/svg+xml",
];
export async function saveFavicon(
formData: FormData,
): Promise<{ success: boolean; url?: string; error?: string }> {
await requirePermission(PERMS.SETTINGS_EDIT);
try {
const file = formData.get("file") as File | null;
if (!file || file.size === 0)
return { success: false, error: "No file provided" };
if (file.size > MAX_SIZE)
return { success: false, error: "File too large (max 2MB)" };
if (!ALLOWED.includes(file.type))
return {
success: false,
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
};
const mimeExt: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/x-icon": "ico",
"image/svg+xml": "svg",
};
const ext = mimeExt[file.type] ?? "png";
const upload = await validateSiteImageUpload(
formData instanceof FormData ? formData.get("file") : null,
{ allowIcon: true },
);
if (!upload.success) return upload;
const ext = upload.extension;
const filename = `favicon-${Date.now()}.${ext}`;
const baseDir = FAVICON_DIR;
const baseDir = resolveMediaPath("favicon");
const filePath = path.resolve(baseDir, filename);
if (!filePath.startsWith(baseDir + path.sep)) {
return { success: false, error: "Invalid path" };
}
const buffer = Buffer.from(await file.arrayBuffer());
const buffer = upload.bytes;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
@@ -85,11 +65,9 @@ export async function saveFavicon(
revalidatePath("/admin/favicon");
return { success: true, url };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
} catch {
logger.error("Site favicon update failed", { module: "site-images" });
return { success: false, error: "Could not update site favicon" };
}
}
@@ -97,10 +75,11 @@ export async function deleteFavicon(): Promise<{
success: boolean;
error?: string;
}> {
await requirePermission(PERMS.SETTINGS_EDIT);
try {
const oldUrl = await siteSettings.get("cms_favicon");
if (oldUrl?.startsWith("/api/media/favicon/")) {
const baseDir = FAVICON_DIR;
const baseDir = resolveMediaPath("favicon");
const oldName = oldUrl.replace("/api/media/favicon/", "");
if (!oldName.includes("..") && !oldName.includes("/")) {
const oldPath = path.resolve(baseDir, oldName);
@@ -127,10 +106,8 @@ export async function deleteFavicon(): Promise<{
revalidatePath("/admin/favicon");
return { success: true };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
} catch {
logger.error("Site favicon update failed", { module: "site-images" });
return { success: false, error: "Could not update site favicon" };
}
}
+15 -22
View File
@@ -3,37 +3,32 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { validateSiteImageUpload } from "@/lib/images/site-image-upload";
import { logger } from "@/lib/logger";
import { resolveMediaPath } from "@/lib/media-storage";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
const MEDIA_DIR = resolveMediaPath("logo");
export async function saveLogo(
formData: FormData,
): Promise<{ success: boolean; url?: string; error?: string }> {
await requirePermission(PERMS.SETTINGS_EDIT);
try {
const file = formData.get("file") as File | null;
if (!file) return { success: false, error: "No file provided" };
const ext =
file.type === "image/png"
? "png"
: file.type === "image/gif"
? "gif"
: file.type === "image/jpeg"
? "jpg"
: file.type === "image/webp"
? "webp"
: "png";
const upload = await validateSiteImageUpload(
formData instanceof FormData ? formData.get("file") : null,
);
if (!upload.success) return upload;
const ext = upload.extension;
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
const baseDir = MEDIA_DIR;
const baseDir = resolveMediaPath("logo");
const filePath = path.resolve(baseDir, filename);
if (!filePath.startsWith(baseDir + path.sep)) {
return { success: false, error: "Invalid path" };
}
const buffer = Buffer.from(await file.arrayBuffer());
const buffer = upload.bytes;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
@@ -50,10 +45,8 @@ export async function saveLogo(
revalidatePath("/", "layout");
return { success: true, url };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
} catch {
logger.error("Site logo update failed", { module: "site-images" });
return { success: false, error: "Could not update site logo" };
}
}
+23
View File
@@ -0,0 +1,23 @@
import { beforeEach, expect, it, vi } from "vitest";
const set = vi.hoisted(() => vi.fn());
vi.mock("next/headers", () => ({ cookies: async () => ({ set }) }));
import { setLocaleCookie } from "./set-locale";
beforeEach(() => vi.clearAllMocks());
it.each(["../../private", "xx", "en\r\nSet-Cookie:bad=1", "", "EN", null, 42])(
"rejects an unsupported locale without writing cookies: %s",
async (value) => {
await setLocaleCookie(value as string);
expect(set).not.toHaveBeenCalled();
},
);
it.each(["en", "it", "nl"])("keeps supported locale %s", async (value) => {
await setLocaleCookie(value);
expect(set).toHaveBeenCalledWith(
"NEXT_LOCALE",
value,
expect.objectContaining({ sameSite: "strict", secure: true }),
);
});
+2
View File
@@ -1,8 +1,10 @@
"use server";
import { cookies } from "next/headers";
import { isSupportedLocale } from "@/i18n/locales";
export async function setLocaleCookie(code: string): Promise<void> {
if (typeof code !== "string" || !isSupportedLocale(code)) return;
const store = await cookies();
store.set("NEXT_LOCALE", code, {
path: "/",
+281
View File
@@ -0,0 +1,281 @@
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { db } from "@/lib/db";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permission-slugs";
import { siteSettings } from "@/lib/services/site-settings";
import { deleteFavicon, saveFavicon } from "./save-favicon";
import { saveLogo } from "./save-logo";
const database = vi.hoisted(() => ({
upsert: vi.fn(),
values: vi.fn(),
where: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs"));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: database.values })),
delete: vi.fn(() => ({ where: database.where })),
},
WebsiteSetting: { key: "key" },
}));
vi.mock("@/lib/media-storage", () => ({
resolveMediaPath: (name: string) => path.resolve("storage/test-media", name),
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { get: vi.fn(), reload: vi.fn() },
}));
vi.mock("node:fs/promises", () => ({
mkdir: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "editor",
});
database.values.mockReturnValue({ onDuplicateKeyUpdate: database.upsert });
});
function form(file: File | string) {
const data = new FormData();
data.set("file", file);
return data;
}
function noMutation() {
expect(mkdir).not.toHaveBeenCalled();
expect(writeFile).not.toHaveBeenCalled();
expect(unlink).not.toHaveBeenCalled();
expect(db.insert).not.toHaveBeenCalled();
expect(db.delete).not.toHaveBeenCalled();
expect(siteSettings.reload).not.toHaveBeenCalled();
}
for (const [name, save] of [
["logo", saveLogo],
["favicon", saveFavicon],
] as const) {
describe(name, () => {
it.each(["anonymous", "settings viewer"])(
"denies %s before reading the upload or settings",
async () => {
const denied = new Error("denied");
vi.mocked(requirePermission).mockRejectedValue(denied);
const data = new FormData();
const read = vi.spyOn(data, "get");
await expect(save(data)).rejects.toBe(denied);
expect(requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
expect(read).not.toHaveBeenCalled();
expect(siteSettings.get).not.toHaveBeenCalled();
noMutation();
},
);
it.each([
[
"SVG",
"image/svg+xml",
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"/>',
],
["SVG disguised as PNG", "image/png", '<svg onload="alert(1)"/>'],
[
"HTML disguised as PNG",
"image/png",
"<!doctype html><script>alert(1)</script>",
],
["unknown MIME", "application/octet-stream", "not an image"],
])(
"rejects %s without changing files or settings",
async (_name, type, content) => {
const result = await save(
form(new File([content], "upload.png", { type })),
);
expect(result.success).toBe(false);
noMutation();
},
);
it("rejects a form string as a file", async () => {
expect((await save(form("image/png"))).success).toBe(false);
noMutation();
});
it("rejects files above 2 MiB before reading their contents", async () => {
const file = new File(
[new Uint8Array(2 * 1024 * 1024 + 1)],
"large.png",
{ type: "image/png" },
);
const data = form(file);
const read = vi.spyOn(data.get("file") as File, "arrayBuffer");
expect((await save(data)).success).toBe(false);
expect(read).not.toHaveBeenCalled();
noMutation();
});
it.each(["png", "jpeg", "gif", "webp"] as const)(
"keeps legitimate %s uploads working",
async (format) => {
const image = await sharp({
create: { width: 2, height: 2, channels: 4, background: "#ff0000" },
})
.toFormat(format)
.toBuffer();
const result = await save(
form(
new File([new Uint8Array(image)], "upload", {
type: `image/${format}`,
}),
),
);
expect(requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
expect(result.success).toBe(true);
expect(result.url).toMatch(
new RegExp(
`^/api/media/${name}/[^/]+\\.${format === "jpeg" ? "jpg" : format}$`,
),
);
expect(writeFile).toHaveBeenCalledWith(expect.any(String), image);
expect(database.values).toHaveBeenCalledWith(
expect.objectContaining({ key: `cms_${name}`, value: result.url }),
);
},
);
it("rejects a raster image whose bytes disagree with its MIME", async () => {
const image = await sharp({
create: { width: 1, height: 1, channels: 4, background: "#000" },
})
.png()
.toBuffer();
expect(
(
await save(
form(
new File([new Uint8Array(image)], "wrong.gif", {
type: "image/gif",
}),
),
)
).success,
).toBe(false);
noMutation();
});
it("does not reveal filesystem errors to the caller", async () => {
const image = await sharp({
create: { width: 1, height: 1, channels: 4, background: "#000" },
})
.png()
.toBuffer();
vi.mocked(writeFile).mockRejectedValue(
new Error("EACCES /private/media/secret.png"),
);
const result = await save(
form(
new File([new Uint8Array(image)], "logo.png", { type: "image/png" }),
),
);
expect(result.success).toBe(false);
expect(result.error).not.toMatch(/EACCES|private|secret/);
expect(logger.error).toHaveBeenCalled();
});
});
}
it("denies favicon deletion before reading settings or touching files", async () => {
const denied = new Error("denied");
vi.mocked(requirePermission).mockRejectedValue(denied);
await expect(deleteFavicon()).rejects.toBe(denied);
expect(requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
expect(siteSettings.get).not.toHaveBeenCalled();
noMutation();
});
it.each(["image/x-icon", "image/vnd.microsoft.icon"])(
"accepts a valid ICO favicon with MIME %s",
async (type) => {
const png = await sharp({
create: { width: 1, height: 1, channels: 4, background: "#000" },
})
.png()
.toBuffer();
const directory = Buffer.alloc(22);
directory.writeUInt16LE(1, 2);
directory.writeUInt16LE(1, 4);
directory[6] = 1;
directory[7] = 1;
directory.writeUInt16LE(1, 10);
directory.writeUInt16LE(32, 12);
directory.writeUInt32LE(png.length, 14);
directory.writeUInt32LE(22, 18);
const bytes = Buffer.concat([directory, png]);
const result = await saveFavicon(
form(new File([new Uint8Array(bytes)], "icon.ico", { type })),
);
expect(result.success).toBe(true);
expect(writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\.ico$/),
bytes,
);
},
);
it("rejects active content behind a forged ICO header", async () => {
const bytes = Buffer.concat([
Buffer.from([0, 0, 1, 0, 1, 0]),
Buffer.from('<svg onload="alert(1)"/>'),
]);
expect(
(
await saveFavicon(
form(
new File([new Uint8Array(bytes)], "icon.ico", {
type: "image/x-icon",
}),
),
)
).success,
).toBe(false);
noMutation();
});
it("rejects a truncated image with a valid PNG signature", async () => {
const bytes = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
expect(
(
await saveLogo(
form(new File([bytes], "image.png", { type: "image/png" })),
)
).success,
).toBe(false);
noMutation();
});
it("rejects disguised SVG before invoking any image decoder", async () => {
const metadata = vi.spyOn(sharp.prototype, "metadata");
try {
const file = new File(
['<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'],
"logo.png",
{ type: "image/png" },
);
expect((await saveLogo(form(file))).success).toBe(false);
expect(metadata).not.toHaveBeenCalled();
noMutation();
} finally {
metadata.mockRestore();
}
});