fix(security): authorize site uploads and harden tokens, media and request identity

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();
+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";
File diff suppressed because it is too large. Load diff
+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: "/",
File diff suppressed because it is too large. Load diff
+50
View File
@@ -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");
});
+20 -3
View File
@@ -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 -1
View File
@@ -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";
+1 -1
View File
@@ -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"
/>
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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;
}
+82
View File
@@ -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);
},
);
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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) {
+2 -1
View File
@@ -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.
+20 -15
View File
@@ -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"
+2 -4
View File
@@ -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
View File
@@ -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";
+38
View File
@@ -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))
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+54
View File
@@ -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");
});
});
+37
View File
@@ -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
View File
@@ -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");
});
});
-1
View File
@@ -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(" ");
+3 -7
View File
@@ -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;
}
}
File diff suppressed because it is too large. Load diff
+3 -9
View 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;
}
}
+68
View File
@@ -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();
});
+15 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,
}),
),
}));
+4 -7
View File
@@ -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),
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "Премахнете",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "Αφαίρεση",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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ń",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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ť",
+1 -1
View File
@@ -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": "Уклони",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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
View File
@@ -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 } });