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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user