fix(security): authorize site uploads and harden tokens, media and request identity
This commit is contained in:
1 parent
52f6d1491f
commit
8abfe352ef
70 files changed
+1609
-204
No files matched your search
@@ -29,7 +29,7 @@ import {
|
||||
isValidVerificationToken,
|
||||
sendVerification,
|
||||
verificationToken,
|
||||
} from "./email-verify";
|
||||
} from "@/lib/auth/email-verification";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -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
@@ -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
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
@@ -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: "/",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import LogoGenerator from "@/components/public/logo-generator";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import LogoPage from "./page";
|
||||
|
||||
const state = vi.hoisted(() => ({ context: null as unknown }));
|
||||
vi.mock("@/actions/save-logo", () => ({ saveLogo: vi.fn() }));
|
||||
vi.mock("@/lib/hotel-name", () => ({
|
||||
resolveHotelName: async () => "Fixture hotel",
|
||||
}));
|
||||
vi.mock("@/lib/permissions", async () => ({
|
||||
...(await import("@/lib/permission-slugs")),
|
||||
getApiAdminContext: async () => state.context,
|
||||
canAccess: (permissions: { has: (slug: string) => boolean }, slug: string) =>
|
||||
permissions.has(slug),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
state.context = null;
|
||||
});
|
||||
|
||||
it.each([
|
||||
["anonymous", null, false],
|
||||
["settings viewer", [PERMS.ADMIN_DASHBOARD, PERMS.SETTINGS_VIEW], false],
|
||||
["editor without housekeeping access", [PERMS.SETTINGS_EDIT], false],
|
||||
["settings editor", [PERMS.ADMIN_DASHBOARD, PERMS.SETTINGS_EDIT], true],
|
||||
] as const)(
|
||||
"offers site-logo saving only to authorized %s",
|
||||
async (_name, slugs, canSave) => {
|
||||
state.context = slugs
|
||||
? {
|
||||
session: { user: { rank: 7 } },
|
||||
permissions: {
|
||||
has: (slug: string) => (slugs as readonly string[]).includes(slug),
|
||||
},
|
||||
}
|
||||
: null;
|
||||
const html = renderToStaticMarkup(await LogoPage());
|
||||
expect(html.includes("Save as site logo")).toBe(canSave);
|
||||
expect(html).toContain("Download PNG");
|
||||
expect(html).toContain("Download all fonts");
|
||||
},
|
||||
);
|
||||
|
||||
it("defaults the generator to download-only without server authorization", () => {
|
||||
const html = renderToStaticMarkup(<LogoGenerator />);
|
||||
expect(html).not.toContain("Save as site logo");
|
||||
expect(html).toContain("Download PNG");
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import LogoGenerator from "@/components/public/logo-generator";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { canAccess, getApiAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export const metadata = { title: "Logo generator" };
|
||||
|
||||
@@ -8,10 +9,26 @@ export const metadata = { title: "Logo generator" };
|
||||
* Public logo generator (AtomCMS logo-generator.blade). Server wrapper that
|
||||
* renders the atom-styled ContentCard header and hands off to the fully
|
||||
* client-side <LogoGenerator>, which does all styling, live preview, and PNG
|
||||
* export in the browser (no server data, no DB).
|
||||
* export in the browser. Saving the site logo requires settings edit access.
|
||||
*/
|
||||
export default async function LogoPage() {
|
||||
const initialText = await resolveHotelName();
|
||||
const [initialText, context] = await Promise.all([
|
||||
resolveHotelName(),
|
||||
getApiAdminContext(),
|
||||
]);
|
||||
const canSaveToSite = Boolean(
|
||||
context &&
|
||||
canAccess(
|
||||
context.permissions,
|
||||
PERMS.ADMIN_DASHBOARD,
|
||||
context.session.user.rank,
|
||||
) &&
|
||||
canAccess(
|
||||
context.permissions,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
context.session.user.rank,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
@@ -27,7 +44,7 @@ export default async function LogoPage() {
|
||||
subtitle="Design a logo for your hotel — pick a font, colours and size, then download it as a PNG."
|
||||
/>
|
||||
|
||||
<LogoGenerator initialText={initialText} />
|
||||
<LogoGenerator initialText={initialText} canSaveToSite={canSaveToSite} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { CheckCircle2, Clock, MailX } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { isValidVerificationToken } from "@/actions/email-verify";
|
||||
import Link from "@/components/link";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { isValidVerificationToken } from "@/lib/auth/email-verification";
|
||||
import { db, User } from "@/lib/db";
|
||||
|
||||
type Status = "verified" | "already" | "invalid" | "unavailable";
|
||||
|
||||
@@ -110,7 +110,7 @@ export function FaviconForm({ currentUrl }: { currentUrl: string | null }) {
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.webp,.ico,.svg"
|
||||
accept=".png,.jpg,.jpeg,.gif,.webp,.ico"
|
||||
required
|
||||
className="block w-full text-sm text-[var(--color-text-readable)] file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-[var(--admin-accent)] file:text-[var(--color-primary-foreground-readable)] cursor-pointer"
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["articles:write"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
if (!(await rateLimit(`article-comment:${uid}`, 10, 60_000)).ok) {
|
||||
|
||||
@@ -303,8 +303,8 @@ async function loadRarityViewer(
|
||||
export async function GET(req: Request) {
|
||||
await connection();
|
||||
try {
|
||||
let userId: number | null = await bearerUserId(req);
|
||||
if (!userId) {
|
||||
let userId: number | null = await bearerUserId(req, ["badges:read"]);
|
||||
if (!req.headers.has("authorization")) {
|
||||
const session = await auth();
|
||||
userId = session?.user?.id ? Number(session.user.id) : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
|
||||
vi.mock("node:fs/promises", () => ({ readFile: vi.fn() }));
|
||||
vi.mock("@/lib/media-storage", () => ({
|
||||
MEDIA_ROOT: path.resolve("storage/test-media"),
|
||||
resolveMediaPath: (name: string) => path.resolve("storage/test-media", name),
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue(Buffer.from("fixture"));
|
||||
});
|
||||
|
||||
async function get(segments: string[]) {
|
||||
return GET(new Request("http://localhost/api/media/test"), {
|
||||
params: Promise.resolve({ path: segments }),
|
||||
});
|
||||
}
|
||||
function secureHeaders(response: Response) {
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(response.headers.get("content-security-policy")).toBe(
|
||||
"default-src 'none'; sandbox",
|
||||
);
|
||||
}
|
||||
|
||||
it.each([
|
||||
["photo.png", "image/png"],
|
||||
["favicon.ico", "image/x-icon"],
|
||||
])("serves %s as an image with protective headers", async (name, mime) => {
|
||||
const response = await get([name]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe(mime);
|
||||
expect(response.headers.get("content-disposition")).toBeNull();
|
||||
secureHeaders(response);
|
||||
});
|
||||
|
||||
it("forces existing SVG files to download", async () => {
|
||||
const response = await get(["favicon", "old.SVG"]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-disposition")).toBe("attachment");
|
||||
secureHeaders(response);
|
||||
});
|
||||
|
||||
it.each([["..", "secret.png"], ["file.html"], ["bad\\file.png"]])(
|
||||
"secures forbidden path %j",
|
||||
async (...segments) => {
|
||||
const response = await get(segments);
|
||||
expect(response.status).toBe(403);
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
secureHeaders(response);
|
||||
},
|
||||
);
|
||||
|
||||
it("secures missing-file responses", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
const response = await get(["missing.png"]);
|
||||
expect(response.status).toBe(404);
|
||||
secureHeaders(response);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ENOENT", 404],
|
||||
["EACCES", 500],
|
||||
])(
|
||||
"handles %s while reading without leaking local paths",
|
||||
async (code, status) => {
|
||||
vi.mocked(readFile).mockRejectedValue(
|
||||
Object.assign(new Error("private/server/path"), { code }),
|
||||
);
|
||||
const response = await get(["image.png"]);
|
||||
expect(response.status).toBe(status);
|
||||
expect(await response.text()).not.toContain("private");
|
||||
secureHeaders(response);
|
||||
},
|
||||
);
|
||||
@@ -2,52 +2,100 @@ import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||
|
||||
const ALLOWED_EXT = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"];
|
||||
const ALLOWED_EXT = [
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".svg",
|
||||
".bmp",
|
||||
".ico",
|
||||
];
|
||||
|
||||
const SECURITY_HEADERS = {
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const { path: segments } = await params;
|
||||
const name = segments.join("/");
|
||||
// Prevent path traversal
|
||||
if (name.includes("..") || name.includes("\\")) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
if (!ALLOWED_EXT.includes(ext)) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
try {
|
||||
const { path: segments } = await params;
|
||||
const name = segments.join("/");
|
||||
// Prevent path traversal
|
||||
if (name.includes("..") || name.includes("\\")) {
|
||||
return new NextResponse("Forbidden", {
|
||||
status: 403,
|
||||
headers: SECURITY_HEADERS,
|
||||
});
|
||||
}
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
if (!ALLOWED_EXT.includes(ext)) {
|
||||
return new NextResponse("Forbidden", {
|
||||
status: 403,
|
||||
headers: SECURITY_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
const baseDir = MEDIA_ROOT;
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
if (!existsSync(filePath)) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
const baseDir = MEDIA_ROOT;
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return new NextResponse("Forbidden", {
|
||||
status: 403,
|
||||
headers: SECURITY_HEADERS,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
if (!existsSync(filePath)) {
|
||||
return new NextResponse("Not found", {
|
||||
status: 404,
|
||||
headers: SECURITY_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
const bytes = await readFile(filePath);
|
||||
const mime: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".bmp": "image/bmp",
|
||||
};
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
const bytes = await readFile(filePath);
|
||||
const mime: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".bmp": "image/bmp",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
return new NextResponse(bytes, {
|
||||
headers: {
|
||||
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
||||
"Content-Type": mime[ext] ?? "application/octet-stream",
|
||||
"Cache-Control": "public, max-age=3600, must-revalidate",
|
||||
},
|
||||
});
|
||||
return new NextResponse(bytes, {
|
||||
headers: {
|
||||
...SECURITY_HEADERS,
|
||||
...(ext === ".svg" ? { "Content-Disposition": "attachment" } : {}),
|
||||
// eslint-disable-next-line security/detect-object-injection -- ext validated against ALLOWED_EXT
|
||||
"Content-Type": mime[ext] ?? "application/octet-stream",
|
||||
"Cache-Control": "public, max-age=3600, must-revalidate",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
)
|
||||
return new NextResponse("Not found", {
|
||||
status: 404,
|
||||
headers: SECURITY_HEADERS,
|
||||
});
|
||||
logger.error("Media read failed", { module: "media" });
|
||||
return new NextResponse("Could not load media", {
|
||||
status: 500,
|
||||
headers: SECURITY_HEADERS,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { db, RadioListenerPoints } from "@/lib/db";
|
||||
// radio_listener_points.points rows for that user_id.
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["radio:read"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
try {
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function GET(_req: Request) {
|
||||
|
||||
// Post a new radio shout as the Bearer-authed user into radio_shouts.
|
||||
export async function POST(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["radio:write"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
if (!(await rateLimit(`radio-shout:${uid}`, 10, 60_000)).ok) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["tickets:write"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
if (!(await rateLimit(`api-ticket-reply:${uid}`, 10, 60_000)).ok) {
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["tickets:read"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
// GET /api/tickets — the authed user's tickets (newest first).
|
||||
export async function GET(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["tickets:read"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
try {
|
||||
@@ -43,7 +43,7 @@ export async function GET(req: Request) {
|
||||
|
||||
// POST /api/tickets — open a new ticket ({ title, content, categoryId? }).
|
||||
export async function POST(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["tickets:write"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
if (!(await rateLimit(`api-ticket:${uid}`, 5, 60_000)).ok) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { issueSsoTicket } from "@/lib/auth/sso-ticket";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { resolveClientIp } from "@/lib/client-ip";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -20,7 +21,7 @@ export default async function ClientPage() {
|
||||
siteSettings.get("nitro_client_url", ""),
|
||||
]);
|
||||
|
||||
const ip = (await headers()).get("x-real-client-ip") ?? "0.0.0.0";
|
||||
const ip = resolveClientIp(await headers());
|
||||
|
||||
// Ticket write and online count run in parallel — the client page should
|
||||
// render as fast as possible since the player is waiting for the game.
|
||||
|
||||
@@ -19,8 +19,10 @@ import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export default function LogoGenerator({
|
||||
initialText = "",
|
||||
canSaveToSite = false,
|
||||
}: {
|
||||
initialText?: string;
|
||||
canSaveToSite?: boolean;
|
||||
}) {
|
||||
const [text, setText] = useState(initialText);
|
||||
const [styleName, setStyleName] = useState("habbo");
|
||||
@@ -106,6 +108,7 @@ export default function LogoGenerator({
|
||||
}, [safeText]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!canSaveToSite) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
setSaveStatus("saving");
|
||||
@@ -123,7 +126,7 @@ export default function LogoGenerator({
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
});
|
||||
}, "image/png");
|
||||
}, []);
|
||||
}, [canSaveToSite]);
|
||||
|
||||
const [zipping, setZipping] = useState(false);
|
||||
const allFonts = useMemo(() => HABBO_FONT_GROUPS.flatMap((g) => g.fonts), []);
|
||||
@@ -252,20 +255,22 @@ export default function LogoGenerator({
|
||||
>
|
||||
⬇️ Download PNG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === "saving" || !fontLoaded}
|
||||
>
|
||||
{saveStatus === "saving"
|
||||
? "⏳ Saving..."
|
||||
: saveStatus === "done"
|
||||
? "✅ Saved!"
|
||||
: saveStatus === "error"
|
||||
? "❌ Error"
|
||||
: "💾 Save as site logo"}
|
||||
</button>
|
||||
{canSaveToSite && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === "saving" || !fontLoaded}
|
||||
>
|
||||
{saveStatus === "saving"
|
||||
? "⏳ Saving..."
|
||||
: saveStatus === "done"
|
||||
? "✅ Saved!"
|
||||
: saveStatus === "error"
|
||||
? "❌ Error"
|
||||
: "💾 Save as site logo"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { unixNow } from "@/lib/bans";
|
||||
import { resolveClientIp } from "@/lib/client-ip";
|
||||
import { Ban, db } from "@/lib/db";
|
||||
import { safeRedirect } from "@/lib/foundation/security";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -26,10 +27,7 @@ function isExempt(path: string): boolean {
|
||||
export async function enforceSiteAccess(): Promise<void> {
|
||||
const h = await headers();
|
||||
const path = h.get("x-pathname") ?? "/";
|
||||
const ip =
|
||||
h.get("x-real-client-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
"0.0.0.0";
|
||||
const ip = resolveClientIp(h);
|
||||
|
||||
void recordRequest(ip).catch(() => {});
|
||||
|
||||
|
||||
+25
-5
@@ -1,6 +1,13 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, eq, gt, isNull, or } from "drizzle-orm";
|
||||
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
|
||||
import {
|
||||
type PersonalTokenAbility,
|
||||
tokenAllowsAbilities,
|
||||
} from "@/lib/auth/personal-token-abilities";
|
||||
import {
|
||||
personalTokenScope,
|
||||
USER_TOKENABLE_TYPE,
|
||||
} from "@/lib/auth/personal-token-scope";
|
||||
import { databaseUserId } from "@/lib/auth/session-user";
|
||||
import { db, PersonalAccessTokens } from "@/lib/db";
|
||||
|
||||
@@ -14,8 +21,11 @@ function hashToken(raw: string): string {
|
||||
return createHash("sha256").update(raw).digest("hex");
|
||||
}
|
||||
|
||||
/** Resolve the user id behind a Bearer token, or null. */
|
||||
export async function bearerUserId(req: Request): Promise<number | null> {
|
||||
/** Resolve an authorized user token; callers without a declared scope require full access. */
|
||||
export async function bearerUserId(
|
||||
req: Request,
|
||||
requiredAbilities: readonly PersonalTokenAbility[] = [],
|
||||
): Promise<number | null> {
|
||||
const header = req.headers.get("authorization") ?? "";
|
||||
const m = header.match(/^Bearer\s+(.+)$/i);
|
||||
if (!m) return null;
|
||||
@@ -29,11 +39,14 @@ export async function bearerUserId(req: Request): Promise<number | null> {
|
||||
.select({
|
||||
id: PersonalAccessTokens.id,
|
||||
tokenableId: PersonalAccessTokens.tokenableId,
|
||||
tokenableType: PersonalAccessTokens.tokenableType,
|
||||
abilities: PersonalAccessTokens.abilities,
|
||||
})
|
||||
.from(PersonalAccessTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(PersonalAccessTokens.token, hashToken(raw)),
|
||||
eq(PersonalAccessTokens.tokenableType, USER_TOKENABLE_TYPE),
|
||||
or(
|
||||
isNull(PersonalAccessTokens.expiresAt),
|
||||
gt(PersonalAccessTokens.expiresAt, new Date()),
|
||||
@@ -41,14 +54,21 @@ export async function bearerUserId(req: Request): Promise<number | null> {
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
if (
|
||||
!row ||
|
||||
row.tokenableType !== USER_TOKENABLE_TYPE ||
|
||||
!tokenAllowsAbilities(row.abilities, requiredAbilities)
|
||||
)
|
||||
return null;
|
||||
const userId = databaseUserId(row.tokenableId);
|
||||
if (userId === null) return null;
|
||||
// Best-effort last-used stamp (don't fail the request if it errors).
|
||||
void db
|
||||
.update(PersonalAccessTokens)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(PersonalAccessTokens.id, row.id))
|
||||
.catch(() => {});
|
||||
return databaseUserId(row.tokenableId);
|
||||
return userId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parse } from "@babel/parser";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("keeps verification token minting and sending out of public server action exports", () => {
|
||||
const forbidden = [
|
||||
"verificationToken",
|
||||
"isValidVerificationToken",
|
||||
"sendVerification",
|
||||
];
|
||||
const exposed: string[] = [];
|
||||
for (const file of readdirSync("src/actions").filter(
|
||||
(file) => file.endsWith(".ts") && !file.endsWith(".test.ts"),
|
||||
)) {
|
||||
const ast = parse(readFileSync(path.join("src/actions", file), "utf8"), {
|
||||
sourceType: "module",
|
||||
plugins: ["typescript"],
|
||||
});
|
||||
if (!ast.program.directives.some((d) => d.value.value === "use server"))
|
||||
continue;
|
||||
for (const item of ast.program.body) {
|
||||
if (item.type !== "ExportNamedDeclaration") continue;
|
||||
if (
|
||||
item.declaration?.type === "FunctionDeclaration" &&
|
||||
forbidden.includes(item.declaration.id?.name ?? "")
|
||||
)
|
||||
exposed.push(`${file}:${item.declaration.id?.name}`);
|
||||
for (const spec of item.specifiers) {
|
||||
const name =
|
||||
spec.exported.type === "Identifier"
|
||||
? spec.exported.name
|
||||
: spec.exported.value;
|
||||
if (forbidden.includes(name)) exposed.push(`${file}:${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(exposed).toEqual([]);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
@@ -0,0 +1,38 @@
|
||||
export type PersonalTokenAbility =
|
||||
| "tickets:read"
|
||||
| "tickets:write"
|
||||
| "articles:write"
|
||||
| "radio:read"
|
||||
| "radio:write"
|
||||
| "badges:read";
|
||||
|
||||
/** Sanctum abilities are JSON; invalid data never grants access. */
|
||||
export function tokenAllowsAbilities(
|
||||
encoded: unknown,
|
||||
required: readonly PersonalTokenAbility[] = [],
|
||||
): boolean {
|
||||
if (typeof encoded !== "string") return false;
|
||||
let abilities: unknown;
|
||||
try {
|
||||
abilities = JSON.parse(encoded);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!Array.isArray(abilities) ||
|
||||
abilities.length === 0 ||
|
||||
!abilities.every(
|
||||
(ability) =>
|
||||
typeof ability === "string" &&
|
||||
ability.length > 0 &&
|
||||
ability.trim() === ability,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
if (abilities.includes("*")) return true;
|
||||
// A caller with no declared scope requires a full-access token.
|
||||
return (
|
||||
required.length > 0 &&
|
||||
required.every((ability) => abilities.includes(ability))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
abilities: '["*"]',
|
||||
queries: [] as string[],
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||
const db = drizzle(async (sql) => {
|
||||
state.queries.push(sql);
|
||||
if (
|
||||
sql.startsWith("select ") &&
|
||||
sql.includes(" from `personal_access_tokens`")
|
||||
) {
|
||||
const token: Record<string, unknown> = {
|
||||
id: "9",
|
||||
tokenable_id: "42",
|
||||
tokenable_type: "App\\Models\\User",
|
||||
abilities: state.abilities,
|
||||
};
|
||||
const columns = sql
|
||||
.slice(7, sql.indexOf(" from "))
|
||||
.split(", ")
|
||||
.map((column) => column.replaceAll("`", ""));
|
||||
return { rows: [columns.map((column) => token[column])] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
return {
|
||||
...schema,
|
||||
db: Object.assign(db, { execute: async () => [[{ cnt: 0n }], []] }),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: "77" } }) }));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
|
||||
vi.mock("next/server", async (original) => ({
|
||||
...(await original<typeof import("next/server")>()),
|
||||
connection: async () => {},
|
||||
}));
|
||||
vi.mock("@/lib/redis-cache", () => ({
|
||||
apiCacheKey: (key: string) => key,
|
||||
cacheSafe: (value: unknown) => value,
|
||||
redisCache: async () => ({
|
||||
badgeStats: [],
|
||||
totalBadges: { entries: [], totalPlayers: 0 },
|
||||
achievementLevel: { entries: [], totalPlayers: 0 },
|
||||
rarity: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { POST as articleComment } from "@/app/api/articles/[slug]/comment/route";
|
||||
import { GET as badgeLeaderboard } from "@/app/api/badges/leaderboard/route";
|
||||
import { GET as radioPoints } from "@/app/api/radio/points/route";
|
||||
import { POST as radioShout } from "@/app/api/radio/shouts/route";
|
||||
import { POST as ticketReply } from "@/app/api/tickets/[id]/reply/route";
|
||||
import { GET as ticketGet } from "@/app/api/tickets/[id]/route";
|
||||
import {
|
||||
GET as ticketsGet,
|
||||
POST as ticketsPost,
|
||||
} from "@/app/api/tickets/route";
|
||||
|
||||
function request(method: string, bearer = true) {
|
||||
return new Request("https://hotel.test/api/test", {
|
||||
method,
|
||||
headers: bearer
|
||||
? {
|
||||
authorization: "Bearer test-token",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
: {},
|
||||
...(method === "POST" ? { body: "{}" } : {}),
|
||||
});
|
||||
}
|
||||
const protectedRoutes = [
|
||||
{
|
||||
name: "GET tickets",
|
||||
scope: "tickets:read",
|
||||
method: "GET",
|
||||
run: ticketsGet,
|
||||
allowedStatus: 200,
|
||||
},
|
||||
{
|
||||
name: "POST tickets",
|
||||
scope: "tickets:write",
|
||||
method: "POST",
|
||||
run: ticketsPost,
|
||||
allowedStatus: 400,
|
||||
},
|
||||
{
|
||||
name: "GET ticket detail",
|
||||
scope: "tickets:read",
|
||||
method: "GET",
|
||||
run: (req: Request) =>
|
||||
ticketGet(req, { params: Promise.resolve({ id: "0" }) }),
|
||||
allowedStatus: 422,
|
||||
},
|
||||
{
|
||||
name: "POST ticket reply",
|
||||
scope: "tickets:write",
|
||||
method: "POST",
|
||||
run: (req: Request) =>
|
||||
ticketReply(req, { params: Promise.resolve({ id: "0" }) }),
|
||||
allowedStatus: 422,
|
||||
},
|
||||
{
|
||||
name: "POST article comment",
|
||||
scope: "articles:write",
|
||||
method: "POST",
|
||||
run: (req: Request) =>
|
||||
articleComment(req, { params: Promise.resolve({ slug: "article" }) }),
|
||||
allowedStatus: 422,
|
||||
},
|
||||
{
|
||||
name: "GET radio points",
|
||||
scope: "radio:read",
|
||||
method: "GET",
|
||||
run: radioPoints,
|
||||
allowedStatus: 200,
|
||||
},
|
||||
{
|
||||
name: "POST radio shout",
|
||||
scope: "radio:write",
|
||||
method: "POST",
|
||||
run: radioShout,
|
||||
allowedStatus: 422,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
state.abilities = '["*"]';
|
||||
state.queries = [];
|
||||
});
|
||||
describe("API endpoint token scope boundaries", () => {
|
||||
it.each(protectedRoutes)(
|
||||
"$name rejects unrelated scopes before accessing endpoint data",
|
||||
async ({ scope, method, run }) => {
|
||||
state.abilities = JSON.stringify([
|
||||
scope.startsWith("tickets:") ? "radio:read" : "tickets:read",
|
||||
]);
|
||||
const response = await run(request(method));
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toEqual({ error: "Unauthorized" });
|
||||
expect(
|
||||
state.queries.every((sql) => sql.includes("personal_access_tokens")),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
it.each(protectedRoutes)(
|
||||
"$name accepts its documented scope",
|
||||
async ({ scope, method, run, allowedStatus }) => {
|
||||
state.abilities = JSON.stringify([scope]);
|
||||
expect((await run(request(method))).status).toBe(allowedStatus);
|
||||
},
|
||||
);
|
||||
it.each(protectedRoutes)(
|
||||
"$name preserves existing wildcard tokens",
|
||||
async ({ method, run, allowedStatus }) => {
|
||||
expect((await run(request(method))).status).toBe(allowedStatus);
|
||||
},
|
||||
);
|
||||
it("does not let a read-only ticket token create a ticket", async () => {
|
||||
state.abilities = '["tickets:read"]';
|
||||
expect((await ticketsPost(request("POST"))).status).toBe(401);
|
||||
});
|
||||
it("does not let a read-only radio token post a shout", async () => {
|
||||
state.abilities = '["radio:read"]';
|
||||
expect((await radioShout(request("POST"))).status).toBe(401);
|
||||
});
|
||||
it("does not use session cookies to bypass a denied bearer scope on the public leaderboard", async () => {
|
||||
state.abilities = '["tickets:read"]';
|
||||
const response = await badgeLeaderboard(request("GET"));
|
||||
expect((await response.json()).viewerUserId).toBe(0);
|
||||
});
|
||||
it("personalizes the leaderboard only for the badges scope", async () => {
|
||||
state.abilities = '["badges:read"]';
|
||||
expect(
|
||||
(await (await badgeLeaderboard(request("GET"))).json()).viewerUserId,
|
||||
).toBe(42);
|
||||
});
|
||||
it("preserves session-only leaderboard personalization without a bearer header", async () => {
|
||||
expect(
|
||||
(await (await badgeLeaderboard(request("GET", false))).json())
|
||||
.viewerUserId,
|
||||
).toBe(77);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
token: {
|
||||
id: "9",
|
||||
tokenable_id: "42",
|
||||
tokenable_type: "App\\Models\\User",
|
||||
abilities: '["*"]',
|
||||
} as Record<string, unknown>,
|
||||
found: true,
|
||||
fail: false,
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
}));
|
||||
vi.mock("@/lib/db", async () => {
|
||||
const schema = await import("@/db/schema");
|
||||
const { drizzle } = await import("drizzle-orm/mysql-proxy");
|
||||
return {
|
||||
...schema,
|
||||
db: drizzle(async (sql, params) => {
|
||||
state.queries.push({ sql, params });
|
||||
if (state.fail) throw Error("database failure containing private data");
|
||||
if (!sql.startsWith("select ")) return { rows: [] };
|
||||
const columns = sql
|
||||
.slice(7, sql.indexOf(" from "))
|
||||
.split(", ")
|
||||
.map((column) => column.replaceAll("`", ""));
|
||||
return {
|
||||
rows: state.found ? [columns.map((column) => state.token[column])] : [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { bearerUserId } from "./api-auth";
|
||||
|
||||
const request = (token = "test-token") =>
|
||||
new Request("https://hotel.test/api/tickets", {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
beforeEach(() => {
|
||||
state.token = {
|
||||
id: "9",
|
||||
tokenable_id: "42",
|
||||
tokenable_type: "App\\Models\\User",
|
||||
abilities: '["*"]',
|
||||
};
|
||||
state.found = true;
|
||||
state.fail = false;
|
||||
state.queries = [];
|
||||
});
|
||||
|
||||
describe("personal bearer token authorization", () => {
|
||||
it.each(["test-token", "9|test-token"])(
|
||||
"preserves full-access token format %s",
|
||||
async (value) => {
|
||||
expect(await bearerUserId(request(value))).toBe(42);
|
||||
expect(state.queries[0].params).toContain(
|
||||
createHash("sha256").update("test-token").digest("hex"),
|
||||
);
|
||||
expect(state.queries[0].params).not.toContain("test-token");
|
||||
},
|
||||
);
|
||||
it("requires the exact user owner model and an unexpired token in the query", async () => {
|
||||
await bearerUserId(request());
|
||||
expect(state.queries[0].sql).toContain("`tokenable_type` = ?");
|
||||
expect(state.queries[0].params).toContain("App\\Models\\User");
|
||||
expect(state.queries[0].sql).toContain("`expires_at` is null");
|
||||
expect(state.queries[0].sql).toContain("`expires_at` > ?");
|
||||
});
|
||||
it.each(["App\\Models\\Admin", "app\\models\\user", "App\\User", ""])(
|
||||
"rejects a token belonging to %s before recording use",
|
||||
async (owner) => {
|
||||
state.token.tokenable_type = owner;
|
||||
expect(await bearerUserId(request())).toBeNull();
|
||||
expect(
|
||||
state.queries.some((query) => query.sql.startsWith("update ")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
null,
|
||||
"",
|
||||
"not-json",
|
||||
'"*"',
|
||||
"{}",
|
||||
"[]",
|
||||
"[null]",
|
||||
'["*",false]',
|
||||
'["tickets:read",""]',
|
||||
])("denies malformed or empty abilities %s", async (abilities) => {
|
||||
state.token.abilities = abilities;
|
||||
expect(await bearerUserId(request())).toBeNull();
|
||||
expect(state.queries.some((query) => query.sql.startsWith("update "))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("does not treat scoped tokens as unrestricted when the caller omits required abilities", async () => {
|
||||
state.token.abilities = '["tickets:read"]';
|
||||
expect(await bearerUserId(request())).toBeNull();
|
||||
expect(await bearerUserId(request(), [])).toBeNull();
|
||||
});
|
||||
it("allows only explicitly granted domains and operations", async () => {
|
||||
state.token.abilities = '["tickets:read","radio:read"]';
|
||||
expect(await bearerUserId(request(), ["tickets:read"])).toBe(42);
|
||||
expect(await bearerUserId(request(), ["tickets:write"])).toBeNull();
|
||||
expect(await bearerUserId(request(), ["articles:write"])).toBeNull();
|
||||
expect(
|
||||
await bearerUserId(request(), ["tickets:read", "tickets:write"]),
|
||||
).toBeNull();
|
||||
});
|
||||
it("retains wildcard compatibility for explicitly scoped endpoints", async () => {
|
||||
expect(await bearerUserId(request(), ["tickets:write", "radio:read"])).toBe(
|
||||
42,
|
||||
);
|
||||
});
|
||||
it("does not interpret domain wildcards or whitespace as permissions", async () => {
|
||||
state.token.abilities = '["tickets:*", " tickets:read"]';
|
||||
expect(await bearerUserId(request(), ["tickets:read"])).toBeNull();
|
||||
});
|
||||
it.each(["0", "9007199254740993"])(
|
||||
"rejects invalid user id %s without recording use",
|
||||
async (id) => {
|
||||
state.token.tokenable_id = id;
|
||||
expect(await bearerUserId(request(), ["tickets:read"])).toBeNull();
|
||||
expect(
|
||||
state.queries.some((query) => query.sql.startsWith("update ")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
it("fails closed on database errors and absent tokens", async () => {
|
||||
state.fail = true;
|
||||
expect(await bearerUserId(request(), ["tickets:read"])).toBeNull();
|
||||
state.fail = false;
|
||||
state.found = false;
|
||||
expect(await bearerUserId(request(), ["tickets:read"])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
headers: new Headers(),
|
||||
headersUnavailable: false,
|
||||
session: null as { user: { id: string } } | null,
|
||||
issueSsoTicket: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
recordRequest: vi.fn(),
|
||||
isIpBlacklisted: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: async () => {
|
||||
if (state.headersUnavailable) throw Error("No request context");
|
||||
return state.headers;
|
||||
},
|
||||
cookies: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/redis", () => ({ redis: null }));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { warn: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("@/lib/auth", () => ({ auth: async () => state.session }));
|
||||
vi.mock("@/lib/auth/sso-ticket", () => ({
|
||||
issueSsoTicket: state.issueSsoTicket,
|
||||
}));
|
||||
vi.mock("@/lib/hotel-name", () => ({
|
||||
resolveHotelName: async () => "Integration",
|
||||
}));
|
||||
vi.mock("@/lib/cache", () => ({ cached: async () => 0 }));
|
||||
vi.mock("@/app/client/client-view", () => ({ ClientView: () => null }));
|
||||
vi.mock("next-auth/jwt", () => ({ getToken: async () => null }));
|
||||
vi.mock("@/lib/services/abuse-guard", () => ({
|
||||
recordRequest: state.recordRequest,
|
||||
isIpBlacklisted: state.isIpBlacklisted,
|
||||
}));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: {
|
||||
getBool: async () => false,
|
||||
get: async () => "/nitro-client/",
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: { insert: () => ({ values: state.insert }) },
|
||||
StaffActivities: {},
|
||||
Ban: {},
|
||||
}));
|
||||
|
||||
import ClientPage from "@/app/client/page";
|
||||
import { proxy } from "@/proxy";
|
||||
import { enforceSiteAccess } from "./access-guard";
|
||||
import { extractClientIpAsync } from "./foundation/security";
|
||||
import { clientIp, rateLimit } from "./rate-limit";
|
||||
import { logStaffActivity } from "./services/staff-activity";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.headers = new Headers({ "x-pathname": "/me" });
|
||||
state.headersUnavailable = false;
|
||||
state.session = null;
|
||||
state.issueSsoTicket.mockResolvedValue("integration-ticket");
|
||||
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||
state.recordRequest.mockResolvedValue(undefined);
|
||||
state.isIpBlacklisted.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe("client IP security consumers", () => {
|
||||
it.each([
|
||||
{
|
||||
headers: {
|
||||
"x-real-client-ip": "198.51.100.99",
|
||||
"x-forwarded-for": "192.0.2.10, 192.0.2.20",
|
||||
},
|
||||
expected: "192.0.2.10",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"x-real-client-ip": "198.51.100.99",
|
||||
"cf-connecting-ip": "2001:DB8:0:0::1",
|
||||
"x-forwarded-for": "192.0.2.10",
|
||||
},
|
||||
expected: "2001:db8::1",
|
||||
},
|
||||
{ headers: { "x-real-client-ip": "198.51.100.99" }, expected: "0.0.0.0" },
|
||||
{
|
||||
headers: {
|
||||
"cf-connecting-ip": "",
|
||||
"x-forwarded-for": "malformed, 192.0.2.10",
|
||||
"x-real-ip": "192.0.2.30",
|
||||
},
|
||||
expected: "192.0.2.30",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"cf-connecting-ip": "invalid",
|
||||
"x-forwarded-for": "",
|
||||
"x-real-ip": "192.0.2.1:8080",
|
||||
},
|
||||
expected: "0.0.0.0",
|
||||
},
|
||||
])(
|
||||
"uses the same validated address for rate limiting, security, access and staff audits: $expected",
|
||||
async ({ headers, expected }) => {
|
||||
for (const [name, value] of Object.entries(headers))
|
||||
state.headers.set(name, value);
|
||||
expect(await clientIp()).toBe(expected);
|
||||
expect(await extractClientIpAsync()).toBe(expected);
|
||||
await enforceSiteAccess();
|
||||
expect(state.recordRequest).toHaveBeenCalledWith(expected);
|
||||
expect(state.isIpBlacklisted).toHaveBeenCalledWith(expected);
|
||||
await logStaffActivity({
|
||||
staffId: 7,
|
||||
action: "test",
|
||||
description: "IP regression",
|
||||
});
|
||||
expect(state.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ipAddress: expected }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the normalized forwarded address for the game ticket", async () => {
|
||||
state.session = { user: { id: "7" } };
|
||||
state.headers.set("x-real-client-ip", "198.51.100.99");
|
||||
state.headers.set("x-forwarded-for", "192.0.2.10, 192.0.2.20");
|
||||
await ClientPage();
|
||||
expect(state.issueSsoTicket).toHaveBeenCalledWith(
|
||||
7,
|
||||
"Integration",
|
||||
"192.0.2.10",
|
||||
);
|
||||
});
|
||||
|
||||
it("cannot obtain another API rate-limit bucket by changing the derived header", async () => {
|
||||
state.headers.set("x-forwarded-for", "192.0.2.10");
|
||||
state.headers.set("x-real-client-ip", "198.51.100.1");
|
||||
const key = `api-ip-regression:${randomUUID()}`;
|
||||
expect((await rateLimit(`${key}:${await clientIp()}`, 1, 60_000)).ok).toBe(
|
||||
true,
|
||||
);
|
||||
state.headers.set("x-real-client-ip", "198.51.100.2");
|
||||
expect((await rateLimit(`${key}:${await clientIp()}`, 1, 60_000)).ok).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the unknown address when request headers are unavailable", async () => {
|
||||
state.headersUnavailable = true;
|
||||
expect(await clientIp()).toBe("0.0.0.0");
|
||||
expect(await extractClientIpAsync()).toBe("0.0.0.0");
|
||||
await logStaffActivity({
|
||||
staffId: 7,
|
||||
action: "test",
|
||||
description: "Missing request",
|
||||
});
|
||||
expect(state.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ipAddress: "0.0.0.0" }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each<Record<string, string>>([{}, { "x-forwarded-for": "192.0.2.10" }])(
|
||||
"removes the incoming derived header from requests forwarded by the proxy",
|
||||
async (forwarded) => {
|
||||
const request = new NextRequest("http://localhost:3000/news", {
|
||||
headers: { ...forwarded, "x-real-client-ip": "198.51.100.99" },
|
||||
});
|
||||
const response = await proxy(request);
|
||||
expect(
|
||||
response.headers.get("x-middleware-request-x-real-client-ip"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
response.headers.get("x-middleware-override-headers"),
|
||||
).not.toContain("x-real-client-ip");
|
||||
expect(response.headers.get("x-middleware-request-x-pathname")).toBe(
|
||||
"/news",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeClientIp, resolveClientIp } from "./client-ip";
|
||||
|
||||
describe("normalized client IP addresses", () => {
|
||||
it.each([
|
||||
[" 192.0.2.1 ", "192.0.2.1"],
|
||||
["2001:DB8:0:0:0:0:0:1", "2001:db8::1"],
|
||||
["2001:db8::1", "2001:db8::1"],
|
||||
["::1", "::1"],
|
||||
["::ffff:192.0.2.1", "192.0.2.1"],
|
||||
["::ffff:c000:201", "192.0.2.1"],
|
||||
])("canonicalizes %s", (input, expected) => {
|
||||
expect(normalizeClientIp(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
"",
|
||||
" ",
|
||||
"unknown",
|
||||
"localhost",
|
||||
"192.0.2.999",
|
||||
"192.000.2.1",
|
||||
"192.0.2.1:8080",
|
||||
"[2001:db8::1]",
|
||||
"[::1]:443",
|
||||
"fe80::1%eth0",
|
||||
"192.0.2.1, 192.0.2.2",
|
||||
"::g",
|
||||
"1".repeat(1000),
|
||||
])("rejects malformed or ambiguous input %s", (input) => {
|
||||
expect(normalizeClientIp(input)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the first forwarded address after an invalid higher-priority header", () => {
|
||||
expect(
|
||||
resolveClientIp(
|
||||
new Headers({
|
||||
"cf-connecting-ip": "invalid",
|
||||
"x-forwarded-for": " 192.0.2.10, 192.0.2.20 ",
|
||||
"x-real-ip": "192.0.2.30",
|
||||
"x-real-client-ip": "198.51.100.99",
|
||||
}),
|
||||
),
|
||||
).toBe("192.0.2.10");
|
||||
});
|
||||
|
||||
it("does not treat a later forwarding hop as the client when the first entry is empty", () => {
|
||||
expect(
|
||||
resolveClientIp(new Headers({ "x-forwarded-for": ", 192.0.2.20" })),
|
||||
).toBe("0.0.0.0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export const UNKNOWN_CLIENT_IP = "0.0.0.0";
|
||||
|
||||
/** Accept bare addresses only, so ports, hostnames and zone IDs cannot become keys. */
|
||||
export function normalizeClientIp(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const address = value?.trim();
|
||||
if (!address || address.length > 45 || address.includes("%")) return null;
|
||||
const version = isIP(address);
|
||||
if (version === 4) return address;
|
||||
if (version !== 6) return null;
|
||||
const canonical = new URL(`http://[${address}]/`).hostname.slice(1, -1);
|
||||
// Treat an IPv4-mapped IPv6 address as the same client as its dotted form.
|
||||
const mapped = /^::ffff:([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(canonical);
|
||||
if (mapped) {
|
||||
const high = Number.parseInt(mapped[1], 16);
|
||||
const low = Number.parseInt(mapped[2], 16);
|
||||
return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwarded headers must be overwritten by a trusted ingress and the origin must
|
||||
* reject direct public access. Header syntax alone cannot establish peer trust.
|
||||
* Never consume x-real-client-ip: API routes bypass the proxy that once set it.
|
||||
*/
|
||||
export function resolveClientIp(headers: Pick<Headers, "get">): string {
|
||||
return (
|
||||
normalizeClientIp(headers.get("cf-connecting-ip")) ??
|
||||
normalizeClientIp(headers.get("x-forwarded-for")?.split(",", 1)[0]) ??
|
||||
normalizeClientIp(headers.get("x-real-ip")) ??
|
||||
UNKNOWN_CLIENT_IP
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -16,6 +16,6 @@ describe("csp", () => {
|
||||
expect(csp).toContain("style-src 'self' 'unsafe-inline'");
|
||||
expect(csp).toContain("style-src-attr 'unsafe-inline'");
|
||||
expect(csp).toContain("https://challenges.cloudflare.com");
|
||||
expect(csp).toContain("https://cdn.jsdelivr.net");
|
||||
expect(csp).not.toContain("https://cdn.jsdelivr.net");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ export function buildContentSecurityPolicy(nonce: string): string {
|
||||
"https://www.google.com/recaptcha/",
|
||||
"https://www.gstatic.com/recaptcha/",
|
||||
"https://static.cloudflareinsights.com",
|
||||
"https://cdn.jsdelivr.net",
|
||||
...(isDev ? ["'unsafe-eval'"] : []),
|
||||
].join(" ");
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { env } from "@/env";
|
||||
import { resolveClientIp, UNKNOWN_CLIENT_IP } from "@/lib/client-ip";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { IpAddress } from "./types";
|
||||
|
||||
@@ -210,14 +211,9 @@ export function sanitizeField(
|
||||
|
||||
export async function extractClientIpAsync(): Promise<IpAddress> {
|
||||
try {
|
||||
const h = await headers();
|
||||
return (h.get("x-real-client-ip") ??
|
||||
h.get("cf-connecting-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
h.get("x-real-ip") ??
|
||||
"0.0.0.0") as IpAddress;
|
||||
return resolveClientIp(await headers()) as IpAddress;
|
||||
} catch {
|
||||
logger.warn("Failed to get client IP from headers");
|
||||
return "0.0.0.0" as IpAddress;
|
||||
return UNKNOWN_CLIENT_IP as IpAddress;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import "server-only";
|
||||
import sharp from "sharp";
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const FORMATS = new Map([
|
||||
["image/png", "png"],
|
||||
["image/jpeg", "jpeg"],
|
||||
["image/gif", "gif"],
|
||||
["image/webp", "webp"],
|
||||
["image/x-icon", "ico"],
|
||||
["image/vnd.microsoft.icon", "ico"],
|
||||
]);
|
||||
|
||||
type ValidatedUpload =
|
||||
| { success: true; bytes: Buffer; extension: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
async function validRaster(bytes: Buffer, format: string) {
|
||||
const signatureMatches =
|
||||
(format === "png" && bytes.subarray(0, 8).equals(PNG_SIGNATURE)) ||
|
||||
(format === "jpeg" &&
|
||||
bytes[0] === 0xff &&
|
||||
bytes[1] === 0xd8 &&
|
||||
bytes[2] === 0xff) ||
|
||||
(format === "gif" &&
|
||||
["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6))) ||
|
||||
(format === "webp" &&
|
||||
bytes.toString("ascii", 0, 4) === "RIFF" &&
|
||||
bytes.toString("ascii", 8, 12) === "WEBP");
|
||||
if (!signatureMatches) return false;
|
||||
const image = sharp(bytes, {
|
||||
failOn: "warning",
|
||||
limitInputPixels: 16 * 1024 * 1024,
|
||||
animated: true,
|
||||
});
|
||||
const metadata = await image.metadata();
|
||||
if (metadata.format !== format) return false;
|
||||
await image.stats();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function validIcon(bytes: Buffer) {
|
||||
if (bytes.length < 22 || bytes.readUInt32LE(0) !== 0x00010000) return false;
|
||||
const count = bytes.readUInt16LE(4);
|
||||
const directoryEnd = 6 + count * 16;
|
||||
if (!count || directoryEnd > bytes.length) return false;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const entry = 6 + i * 16;
|
||||
const length = bytes.readUInt32LE(entry + 8);
|
||||
const offset = bytes.readUInt32LE(entry + 12);
|
||||
if (offset < directoryEnd || length < 12 || offset + length > bytes.length)
|
||||
return false;
|
||||
const frame = bytes.subarray(offset, offset + length);
|
||||
if (frame.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
||||
if (!(await validRaster(frame, "png"))) return false;
|
||||
continue;
|
||||
}
|
||||
// Classic ICO frames contain a DIB header rather than a standalone BMP.
|
||||
const headerSize = frame.readUInt32LE(0);
|
||||
if (
|
||||
![12, 40, 52, 56, 108, 124].includes(headerSize) ||
|
||||
length <= headerSize
|
||||
)
|
||||
return false;
|
||||
const width = bytes[entry] || 256;
|
||||
const height = bytes[entry + 1] || 256;
|
||||
const core = headerSize === 12;
|
||||
if (
|
||||
(core ? frame.readUInt16LE(4) : frame.readInt32LE(4)) !== width ||
|
||||
(core ? frame.readUInt16LE(6) : frame.readInt32LE(8)) !== height * 2 ||
|
||||
frame.readUInt16LE(core ? 8 : 12) !== 1 ||
|
||||
![1, 4, 8, 16, 24, 32].includes(frame.readUInt16LE(core ? 10 : 14))
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function validateSiteImageUpload(
|
||||
value: unknown,
|
||||
{ allowIcon = false }: { allowIcon?: boolean } = {},
|
||||
): Promise<ValidatedUpload> {
|
||||
if (!(value instanceof File) || value.size === 0)
|
||||
return { success: false, error: "No file provided" };
|
||||
if (value.size > MAX_BYTES)
|
||||
return { success: false, error: "File too large (max 2MB)" };
|
||||
const format = FORMATS.get(value.type);
|
||||
if (!format || (format === "ico" && !allowIcon))
|
||||
return { success: false, error: "Unsupported image type" };
|
||||
try {
|
||||
const bytes = Buffer.from(await value.arrayBuffer());
|
||||
const valid =
|
||||
format === "ico"
|
||||
? await validIcon(bytes)
|
||||
: await validRaster(bytes, format);
|
||||
if (!valid) return { success: false, error: "Invalid image file" };
|
||||
return {
|
||||
success: true,
|
||||
bytes,
|
||||
extension: format === "jpeg" ? "jpg" : format,
|
||||
};
|
||||
} catch {
|
||||
return { success: false, error: "Invalid image file" };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { headers } from "next/headers";
|
||||
import { resolveClientIp, UNKNOWN_CLIENT_IP } from "@/lib/client-ip";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
@@ -89,15 +90,8 @@ export async function rateLimit(
|
||||
|
||||
export async function clientIp(): Promise<string> {
|
||||
try {
|
||||
const h = await headers();
|
||||
return (
|
||||
h.get("x-real-client-ip") ??
|
||||
h.get("cf-connecting-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
h.get("x-real-ip") ??
|
||||
"0.0.0.0"
|
||||
);
|
||||
return resolveClientIp(await headers());
|
||||
} catch {
|
||||
return "0.0.0.0";
|
||||
return UNKNOWN_CLIENT_IP;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
exec: vi.fn(),
|
||||
write: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
api: vi.fn(),
|
||||
env: {
|
||||
RESEND_API_KEY: "",
|
||||
SMTP_FROM: "Hotel <[email protected]>",
|
||||
HOTEL_NAME: "Hotel",
|
||||
},
|
||||
}));
|
||||
vi.mock("node:child_process", () => ({ exec: mocks.exec }));
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
writeFile: mocks.write,
|
||||
mkdir: mocks.mkdir,
|
||||
}));
|
||||
vi.mock("@/env", () => ({ env: mocks.env }));
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
vi.mock("resend", () => ({
|
||||
Resend: class {
|
||||
emails = { send: mocks.api };
|
||||
},
|
||||
}));
|
||||
|
||||
import { sendMail } from "./email";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.env.SMTP_FROM = "Hotel <[email protected]>";
|
||||
mocks.exec.mockImplementation((_command, callback) => {
|
||||
callback(null);
|
||||
return { stdin: { write: vi.fn(), end: vi.fn() } };
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
["[email protected]\r\nBcc: [email protected]", "Hello"],
|
||||
["[email protected]", "Hello\nBcc: [email protected]"],
|
||||
["[email protected]", "Hi\u0000bad"],
|
||||
])(
|
||||
"rejects header injection before contacting any mail transport",
|
||||
async (to, subject) => {
|
||||
expect(await sendMail(to, subject, "<p>Test</p>")).toBe(false);
|
||||
expect(mocks.exec).not.toHaveBeenCalled();
|
||||
expect(mocks.api).not.toHaveBeenCalled();
|
||||
expect(mocks.write).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("also rejects an unsafe configured sender", async () => {
|
||||
mocks.env.SMTP_FROM = "[email protected]\r\nBcc: [email protected]";
|
||||
expect(await sendMail("[email protected]", "Hello", "<p>Test</p>")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(mocks.exec).not.toHaveBeenCalled();
|
||||
});
|
||||
it("preserves legitimate unicode subjects and HTML body newlines", async () => {
|
||||
expect(
|
||||
await sendMail(
|
||||
"[email protected]",
|
||||
"Novità dell’hotel",
|
||||
"<p>Hi</p>\n<p>Welcome</p>",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(mocks.exec).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -73,7 +73,7 @@ async function writeToFile(
|
||||
}
|
||||
}
|
||||
|
||||
/** Send an HTML email. Tries Resend → local sendmail → file fallback. Always returns true. */
|
||||
/** Send through configured transports; reject unsafe headers before any I/O. */
|
||||
export async function sendMail(
|
||||
to: string,
|
||||
subject: string,
|
||||
@@ -81,6 +81,20 @@ export async function sendMail(
|
||||
): Promise<boolean> {
|
||||
const from = env.SMTP_FROM ?? `no-reply@${env.HOTEL_NAME}`;
|
||||
|
||||
if (
|
||||
[to, subject, from].some(
|
||||
(value) =>
|
||||
typeof value !== "string" ||
|
||||
!value.trim() ||
|
||||
Array.from(value).some(
|
||||
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
|
||||
),
|
||||
)
|
||||
) {
|
||||
logger.warn("Email rejected: invalid header value", { module: "email" });
|
||||
return false;
|
||||
}
|
||||
|
||||
const r = getResend();
|
||||
if (r) {
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock("next/headers", () => ({
|
||||
new Promise((resolve) =>
|
||||
resolve({
|
||||
get: (key: string) =>
|
||||
key === "x-real-client-ip" ? "192.168.1.1" : null,
|
||||
key === "x-forwarded-for" ? "192.168.1.1" : null,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { headers } from "next/headers";
|
||||
import { resolveClientIp, UNKNOWN_CLIENT_IP } from "@/lib/client-ip";
|
||||
import { db, StaffActivities } from "@/lib/db";
|
||||
|
||||
/**
|
||||
@@ -13,15 +14,11 @@ export async function logStaffActivity(opts: {
|
||||
targetId?: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
let ip: string | null = null;
|
||||
let ip = UNKNOWN_CLIENT_IP;
|
||||
try {
|
||||
const h = await headers();
|
||||
ip =
|
||||
h.get("x-real-client-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
null;
|
||||
ip = resolveClientIp(await headers());
|
||||
} catch {
|
||||
ip = null;
|
||||
// Some background actions have no request context.
|
||||
}
|
||||
await db.insert(StaffActivities).values({
|
||||
userId: BigInt(opts.staffId),
|
||||
|
||||
@@ -3243,7 +3243,7 @@
|
||||
"subtitle": "Upload and manage the site favicon",
|
||||
"current": "Current favicon",
|
||||
"uploadLabel": "Upload favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG. Max 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading…",
|
||||
"delete": "Remove",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Качете и управлявайте favicon на сайта",
|
||||
"current": "Текущ favicon",
|
||||
"uploadLabel": "Качване на фавикон",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO или SVG. Макс 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Качване",
|
||||
"uploading": "Качване...",
|
||||
"delete": "Премахнете",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Nahrajte a spravujte favicon webu",
|
||||
"current": "Aktuální favicon",
|
||||
"uploadLabel": "Nahrát favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO nebo SVG. Maximálně 2 MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Nahrát",
|
||||
"uploading": "Nahrávání…",
|
||||
"delete": "Odebrat",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Upload og administrer webstedets favicon",
|
||||
"current": "Nuværende favicon",
|
||||
"uploadLabel": "Upload favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO eller SVG. Max 2 MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploader...",
|
||||
"delete": "Fjern",
|
||||
|
||||
@@ -3337,7 +3337,7 @@
|
||||
"subtitle": "Upload and manage the site favicon",
|
||||
"current": "Current favicon",
|
||||
"uploadLabel": "Upload favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG. Max 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading…",
|
||||
"delete": "Remove",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Ανεβάστε και διαχειριστείτε το favicon του ιστότοπου",
|
||||
"current": "Τρέχον favicon",
|
||||
"uploadLabel": "Μεταφόρτωση favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO ή SVG. Μέγιστο 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Μεταφόρτωση",
|
||||
"uploading": "Μεταφόρτωση…",
|
||||
"delete": "Αφαίρεση",
|
||||
|
||||
@@ -3938,7 +3938,7 @@
|
||||
"subtitle": "Upload and manage the site favicon",
|
||||
"current": "Current favicon",
|
||||
"uploadLabel": "Upload favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG. Max 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading…",
|
||||
"delete": "Remove",
|
||||
|
||||
@@ -3337,7 +3337,7 @@
|
||||
"subtitle": "Upload and manage the site favicon",
|
||||
"current": "Current favicon",
|
||||
"uploadLabel": "Upload favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG. Max 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading…",
|
||||
"delete": "Remove",
|
||||
|
||||
@@ -3243,7 +3243,7 @@
|
||||
"subtitle": "Upload and manage the site favicon",
|
||||
"current": "Current favicon",
|
||||
"uploadLabel": "Upload favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG. Max 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading…",
|
||||
"delete": "Remove",
|
||||
|
||||
@@ -3337,7 +3337,7 @@
|
||||
"subtitle": "Upload and manage the site favicon",
|
||||
"current": "Current favicon",
|
||||
"uploadLabel": "Upload favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG. Max 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading…",
|
||||
"delete": "Remove",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Učitajte favicon stranice i upravljajte njome",
|
||||
"current": "Trenutačni favicon",
|
||||
"uploadLabel": "Prenesi favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO ili SVG. Maksimalno 2 MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Upload",
|
||||
"uploading": "Prijenos…",
|
||||
"delete": "Ukloniti",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Töltse fel és kezelje a webhely faviconját",
|
||||
"current": "Aktuális kedvenc",
|
||||
"uploadLabel": "Favicon feltöltése",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO vagy SVG. Max 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Feltöltés",
|
||||
"uploading": "Feltöltés…",
|
||||
"delete": "Távolítsa el",
|
||||
|
||||
@@ -3898,7 +3898,7 @@
|
||||
"subtitle": "Upload and manage the site favicon",
|
||||
"current": "Current favicon",
|
||||
"uploadLabel": "Upload favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG. Max 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP o ICO. Massimo 2 MB.",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading…",
|
||||
"delete": "Remove",
|
||||
|
||||
@@ -3243,7 +3243,7 @@
|
||||
"subtitle": "Upload and manage the site favicon",
|
||||
"current": "Current favicon",
|
||||
"uploadLabel": "Upload favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG. Max 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading…",
|
||||
"delete": "Remove",
|
||||
|
||||
@@ -3669,7 +3669,7 @@
|
||||
"subtitle": "Upload en beheer de favicon van de site",
|
||||
"current": "Huidige favicon",
|
||||
"uploadLabel": "Favicon uploaden",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO of SVG. Max 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP of ICO. Maximaal 2 MB.",
|
||||
"upload": "Uploaden",
|
||||
"uploading": "Bezig met uploaden…",
|
||||
"delete": "Verwijderen",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Last opp og administrer nettstedets favorittikon",
|
||||
"current": "Gjeldende favorittikon",
|
||||
"uploadLabel": "Last opp favorittikon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO eller SVG. Maks 2 MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Last opp",
|
||||
"uploading": "Laster opp …",
|
||||
"delete": "Fjern",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Przesyłaj favikonę witryny i zarządzaj nią",
|
||||
"current": "Bieżąca ikona ulubionych",
|
||||
"uploadLabel": "Prześlij favikonę",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO lub SVG. Maks. 2 MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Prześlij",
|
||||
"uploading": "Przesyłanie…",
|
||||
"delete": "Usuń",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Carregar e gerenciar o favicon do site",
|
||||
"current": "Favicon atual",
|
||||
"uploadLabel": "Carregar favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO ou SVG. Máximo de 2 MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Carregar",
|
||||
"uploading": "Fazendo upload…",
|
||||
"delete": "Remover",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Încărcați și gestionați favicon-ul site-ului",
|
||||
"current": "Favicon actual",
|
||||
"uploadLabel": "Încărcați favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO sau SVG. Maxim 2 MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Încărcați",
|
||||
"uploading": "Se încarcă...",
|
||||
"delete": "Eliminați",
|
||||
|
||||
@@ -3336,7 +3336,7 @@
|
||||
"subtitle": "Upload and manage the site favicon",
|
||||
"current": "Current favicon",
|
||||
"uploadLabel": "Upload favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG. Max 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Upload",
|
||||
"uploading": "Uploading…",
|
||||
"delete": "Remove",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Nahrajte a spravujte favicon stránky",
|
||||
"current": "Aktuálna favicon",
|
||||
"uploadLabel": "Nahrať favicon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO alebo SVG. Maximálne 2 MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Nahrať",
|
||||
"uploading": "Nahráva sa…",
|
||||
"delete": "Odstrániť",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Upload and manage the site favicon",
|
||||
"current": "Тренутни фавицон",
|
||||
"uploadLabel": "Уплоад фавицон",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG. Мак 2МБ.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Уплоад",
|
||||
"uploading": "Отпремање…",
|
||||
"delete": "Уклони",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Ladda upp och hantera webbplatsens favoritikon",
|
||||
"current": "Aktuell favicon",
|
||||
"uploadLabel": "Ladda upp favoritikon",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO eller SVG. Max 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Ladda upp",
|
||||
"uploading": "Laddar upp...",
|
||||
"delete": "Ta bort",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Site favicon'unu yükleyin ve yönetin",
|
||||
"current": "Mevcut site simgesi",
|
||||
"uploadLabel": "Sık kullanılan simgeyi yükle",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO veya SVG. Maksimum 2MB.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Yükle",
|
||||
"uploading": "Yükleniyor…",
|
||||
"delete": "Kaldır",
|
||||
|
||||
@@ -3338,7 +3338,7 @@
|
||||
"subtitle": "Завантажте фавікон сайту та керуйте ним",
|
||||
"current": "Поточний фавікон",
|
||||
"uploadLabel": "Завантажити фавікон",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP, ICO або SVG. Макс. 2 МБ.",
|
||||
"uploadHint": "PNG, JPEG, GIF, WebP or ICO. Max 2 MB.",
|
||||
"upload": "Завантажити",
|
||||
"uploading": "Завантаження…",
|
||||
"delete": "видалити",
|
||||
|
||||
+2
-6
@@ -31,12 +31,8 @@ export const proxy = async (req: import("next/server").NextRequest) => {
|
||||
headers.set("x-pathname", req.nextUrl.pathname);
|
||||
headers.set("x-nonce", nonce);
|
||||
|
||||
const ip =
|
||||
req.headers.get("cf-connecting-ip") ??
|
||||
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
req.headers.get("x-real-ip") ??
|
||||
"";
|
||||
if (ip) headers.set("x-real-client-ip", ip);
|
||||
// A client may supply this legacy derived header; no consumer should trust it.
|
||||
headers.delete("x-real-client-ip");
|
||||
|
||||
const response = NextResponse.next({ request: { headers } });
|
||||
|
||||
|
||||
Reference in new issue
Block a user