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

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

No files matched your search

+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);
},
);
+86 -38
View File
@@ -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,
});
}
}
+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.