diff --git a/docs/operations/docker-installation.md b/docs/operations/docker-installation.md index d5eaf4d9..5b0b6e2e 100644 --- a/docs/operations/docker-installation.md +++ b/docs/operations/docker-installation.md @@ -16,6 +16,14 @@ Credentials are entered on the host, never in the dashboard. Do not paste `.env` After startup, visit `/admin/devops/installation` with the appropriate permission. Verify database, Redis, storage and migration status. Worker heartbeat is a separate runtime signal: a healthy HTTP endpoint does not prove an import worker is processing jobs. Check the worker status and investigate a missing/stale heartbeat before scheduling imports. The dashboard is read-only and cannot start Docker, upgrade the host or grant registry access. +## Client IP trust at the reverse proxy + +The application validates and normalizes client addresses from `cf-connecting-ip`, the first `x-forwarded-for` entry, then `x-real-ip`. It never accepts `x-real-client-ip`; that legacy derived header is also stripped by the Next.js proxy. Missing or invalid addresses resolve to `0.0.0.0` for rate limits and audit records. API routes use the same resolver even though they do not run through the Next.js proxy. + +These headers are trustworthy only when the ingress sanitizes them. Configure the reverse proxy to discard client-supplied forwarding/derived headers and replace the accepted address from a verified connection or a specifically trusted upstream proxy. Do not append an untrusted incoming `x-forwarded-for` chain and then treat its first entry as authoritative. Forward `cf-connecting-ip` only after verifying that it came through your trusted CDN path; otherwise remove it. + +Restrict direct access to the application port so requests must pass through that ingress. The provided Compose file uses host networking with `HOSTNAME=0.0.0.0`; it does not enforce this restriction or provision nginx/Traefik trust rules. Verify the host firewall and actual reverse-proxy configuration before relying on client IPs for blocking, auditing or abuse limits. Repository tests prove rejection of the derived-header bypass and malformed addresses; they do not certify the deployed forwarding trust chain. + ## Routine and selected-release updates ```sh diff --git a/docs/operations/personal-tokens/README.md b/docs/operations/personal-tokens/README.md new file mode 100644 index 00000000..9b62e946 --- /dev/null +++ b/docs/operations/personal-tokens/README.md @@ -0,0 +1,26 @@ +# Personal API token scopes + +Public API bearer authentication accepts only tokens owned by the exact `App\Models\User` model. The owner ID must be a positive, safely representable user ID, and the token must satisfy its existing expiration check. Both plaintext tokens and the existing `{id}|{plaintext}` request format remain supported; only the SHA-256 hash is looked up in the database. + +The `abilities` column must contain a non-empty JSON array of non-empty strings. Null, malformed JSON, non-array JSON, empty arrays, non-string entries, and entries with surrounding whitespace are rejected. A valid `"*"` entry grants access to all existing bearer-protected endpoints. Other permissions match exactly: there is no `tickets:*` expansion, implicit read/write inheritance, or fallback to unrestricted access. + +| Ability | Endpoint access | +| --- | --- | +| `tickets:read` | `GET /api/tickets`, `GET /api/tickets/{id}` | +| `tickets:write` | `POST /api/tickets`, `POST /api/tickets/{id}/reply` | +| `articles:write` | `POST /api/articles/{slug}/comment` | +| `radio:read` | `GET /api/radio/points` | +| `radio:write` | `POST /api/radio/shouts` | +| `badges:read` | Personal viewer data in `GET /api/badges/leaderboard` | + +For example, `["tickets:read","radio:read"]` allows reading the owner's tickets and radio points. It cannot create tickets, send replies, post article comments, or send radio shouts. Endpoint ownership checks and rate limits still apply after scope authorization. + +Required-token endpoints return the existing generic `401 Unauthorized` response when authorization fails. The badge leaderboard remains public: a denied bearer token receives the anonymous view, without personal viewer data. When an Authorization header is present, this endpoint does not use a session cookie to bypass a denied token. Session-only requests continue to personalize the leaderboard normally. + +## Compatibility and maintenance + +Existing valid wildcard tokens remain compatible. The existing session-authenticated `POST /api/tokens` endpoint continues issuing `["*"]`; this change does not add token-creation options or alter stored tokens. Legacy null, malformed, empty, differently cased model names, and unrelated model tokens are intentionally denied. Review and replace affected tokens with explicit intended scopes, or reissue through the existing token endpoint when full access is appropriate. + +Every new bearer-authenticated endpoint must pass its required abilities to `bearerUserId`. Multiple required abilities use AND semantics. Omitting the requirements, or passing an empty list, requires a wildcard token rather than granting arbitrary scoped tokens access. + +No plaintext token or stored hash is added to error responses or logs by these checks. The existing issuance endpoint returns plaintext once by design. diff --git a/docs/operations/security-review-2026-09-13.md b/docs/operations/security-review-2026-09-13.md new file mode 100644 index 00000000..1de7d2c4 --- /dev/null +++ b/docs/operations/security-review-2026-09-13.md @@ -0,0 +1,31 @@ +# Security report verification — 2026-09-13 + +The supplied review describes commit `baeb54ae` plus a separate port for another hotel. Its “Fixed” labels were not evidence that the changes existed in EpicNext-Cms. This verification inspected canonical `main` at `52f6d149` and the corrective changes prepared here. No exploit or authenticated mutation was performed against production. + +| Supplied finding | Verified state in baseline | Correction / remaining boundary | +| --- | --- | --- | +| 1. Logo authorization/upload | Confirmed missing action permission and per-file validation | Require settings edit before input or storage access; bounded decoded raster uploads | +| 2. Favicon authorization/delete | Confirmed missing action permission; SVG accepted | Same permission boundary for create/delete, bounded raster/ICO validation | +| 3. Active uploaded SVG | Confirmed SVG served inline without route CSP | Route CSP sandbox and nosniff on success/errors; existing SVG served as attachment | +| 4. Client IP spoofing | Confirmed direct trust in caller-controlled `x-real-client-ip` | Shared validated resolver ignores that header. Forwarded headers still require trusted ingress that overwrites them and prevents direct public origin access | +| 5. Email token action exports | Confirmed token helpers in a `use server` module | Move token creation/validation and delivery to a server-only module. Registration and verification call it internally | +| 6. Locale cookie | Confirmed missing allowlist | Supported locales only, validate before reading/writing cookies | +| 7. Email header injection | Confirmed unsanitized values in sendmail headers | Reject control characters before any mail transport or file fallback; includes configured sender | +| 8. Gateway CORS | Supplied gateway path is outside this repository | Read-only GET to our `/api/health` with an unrelated Origin returned a fixed `https://epicnabbo.nl` allow-origin and no allow-credentials. This does not reproduce the report on that route, nor certify every host/route | +| 9. Token abilities | Confirmed abilities and owner type not checked by bearer authentication | Enforce User owner type and explicit endpoint abilities; existing wildcard user tokens remain supported | +| 10. Broad script CDN | Confirmed unrestricted jsDelivr script source, without a source-code consumer | Remove the broad script source; retain required captcha/analytics sources and nonce | + +The additional `withNitroStaff` code and its tests mentioned in the supplied port do not exist in this checkout; they were not assumed to have been reviewed or imported. + +## Evidence and limits + +- Regression tests exercise authorization before I/O, actual file decoding, SVG/error response headers, token-boundary exports, token abilities, forged derived-IP headers across consumers, locale values, and mail header control characters. +- An updated `pnpm audit --json` reported zero known advisories. This is a dependency database result, not proof that application code has no vulnerabilities. +- Next.js treats exported Server Actions as public endpoints; unused actions can also be removed by the compiler. The email refactor removes the action boundary entirely instead of relying on whether a specific build exports an unused helper. See [Next.js data security](https://nextjs.org/docs/app/guides/data-security). +- The framework also has its own Server Action body limit. The logo defect was absence of application-level file validation, not evidence of literally unlimited bytes through every deployment layer. +- No live database, user accounts, uploaded files, or gateway configuration were modified during verification. These changes do not constitute a penetration test or an audit of the emulator, host, or all CMS endpoints. +- No nginx/Traefik ingress configuration is versioned here. The deployment guide records the forwarding-header trust requirement. That external boundary remains unverified. + +## Follow-up identified during verification + +The article-comment REST endpoint needs a separate review of publication visibility and moderation parity with the website form. This was discovered while enumerating bearer consumers; it is not silently treated as covered by the supplied review. diff --git a/src/actions/email-verify.test.ts b/src/actions/email-verify.test.ts index fb200193..d29de6d3 100644 --- a/src/actions/email-verify.test.ts +++ b/src/actions/email-verify.test.ts @@ -29,7 +29,7 @@ import { isValidVerificationToken, sendVerification, verificationToken, -} from "./email-verify"; +} from "@/lib/auth/email-verification"; beforeEach(() => { vi.clearAllMocks(); diff --git a/src/actions/register.ts b/src/actions/register.ts index 3a25946e..ddcc73f0 100644 --- a/src/actions/register.ts +++ b/src/actions/register.ts @@ -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"; diff --git a/src/actions/save-favicon.ts b/src/actions/save-favicon.ts index 6e3e54fe..ff1c5b27 100644 --- a/src/actions/save-favicon.ts +++ b/src/actions/save-favicon.ts @@ -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 = { - "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" }; } } diff --git a/src/actions/save-logo.ts b/src/actions/save-logo.ts index 6fe690cd..90653dd9 100644 --- a/src/actions/save-logo.ts +++ b/src/actions/save-logo.ts @@ -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" }; } } diff --git a/src/actions/set-locale.test.ts b/src/actions/set-locale.test.ts new file mode 100644 index 00000000..5999c8df --- /dev/null +++ b/src/actions/set-locale.test.ts @@ -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 }), + ); +}); diff --git a/src/actions/set-locale.ts b/src/actions/set-locale.ts index 6fcddafb..a2a4dc05 100644 --- a/src/actions/set-locale.ts +++ b/src/actions/set-locale.ts @@ -1,8 +1,10 @@ "use server"; import { cookies } from "next/headers"; +import { isSupportedLocale } from "@/i18n/locales"; export async function setLocaleCookie(code: string): Promise { + if (typeof code !== "string" || !isSupportedLocale(code)) return; const store = await cookies(); store.set("NEXT_LOCALE", code, { path: "/", diff --git a/src/actions/upload-actions-authz.test.ts b/src/actions/upload-actions-authz.test.ts new file mode 100644 index 00000000..00f44ec3 --- /dev/null +++ b/src/actions/upload-actions-authz.test.ts @@ -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 disguised as PNG", "image/png", ''], + [ + "HTML disguised as PNG", + "image/png", + "", + ], + ["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(''), + ]); + 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( + [''], + "logo.png", + { type: "image/png" }, + ); + expect((await saveLogo(form(file))).success).toBe(false); + expect(metadata).not.toHaveBeenCalled(); + noMutation(); + } finally { + metadata.mockRestore(); + } +}); diff --git a/src/app/(site)/logo/page.test.tsx b/src/app/(site)/logo/page.test.tsx new file mode 100644 index 00000000..634b367f --- /dev/null +++ b/src/app/(site)/logo/page.test.tsx @@ -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(); + expect(html).not.toContain("Save as site logo"); + expect(html).toContain("Download PNG"); +}); diff --git a/src/app/(site)/logo/page.tsx b/src/app/(site)/logo/page.tsx index d74f5b59..dd38b84d 100644 --- a/src/app/(site)/logo/page.tsx +++ b/src/app/(site)/logo/page.tsx @@ -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 , 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 (
- +
); } diff --git a/src/app/(site)/verify/page.tsx b/src/app/(site)/verify/page.tsx index 5009e782..753a92ba 100644 --- a/src/app/(site)/verify/page.tsx +++ b/src/app/(site)/verify/page.tsx @@ -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"; diff --git a/src/app/admin/favicon/favicon-form.tsx b/src/app/admin/favicon/favicon-form.tsx index fa9a885b..ad4f8a7d 100644 --- a/src/app/admin/favicon/favicon-form.tsx +++ b/src/app/admin/favicon/favicon-form.tsx @@ -110,7 +110,7 @@ export function FaviconForm({ currentUrl }: { currentUrl: string | null }) { diff --git a/src/app/api/articles/[slug]/comment/route.ts b/src/app/api/articles/[slug]/comment/route.ts index 9125eb56..01b33385 100644 --- a/src/app/api/articles/[slug]/comment/route.ts +++ b/src/app/api/articles/[slug]/comment/route.ts @@ -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) { diff --git a/src/app/api/badges/leaderboard/route.ts b/src/app/api/badges/leaderboard/route.ts index 5a4b96b2..503b9665 100644 --- a/src/app/api/badges/leaderboard/route.ts +++ b/src/app/api/badges/leaderboard/route.ts @@ -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; } diff --git a/src/app/api/media/[...path]/route.test.ts b/src/app/api/media/[...path]/route.test.ts new file mode 100644 index 00000000..87aa63e1 --- /dev/null +++ b/src/app/api/media/[...path]/route.test.ts @@ -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); + }, +); diff --git a/src/app/api/media/[...path]/route.ts b/src/app/api/media/[...path]/route.ts index cbfc0540..17ace14a 100644 --- a/src/app/api/media/[...path]/route.ts +++ b/src/app/api/media/[...path]/route.ts @@ -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 = { - ".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 = { + ".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, + }); + } } diff --git a/src/app/api/radio/points/route.ts b/src/app/api/radio/points/route.ts index fb54f1e5..0e77bc0f 100644 --- a/src/app/api/radio/points/route.ts +++ b/src/app/api/radio/points/route.ts @@ -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 { diff --git a/src/app/api/radio/shouts/route.ts b/src/app/api/radio/shouts/route.ts index 8688fe3e..c81c5b6a 100644 --- a/src/app/api/radio/shouts/route.ts +++ b/src/app/api/radio/shouts/route.ts @@ -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) { diff --git a/src/app/api/tickets/[id]/reply/route.ts b/src/app/api/tickets/[id]/reply/route.ts index d6146370..d6928703 100644 --- a/src/app/api/tickets/[id]/reply/route.ts +++ b/src/app/api/tickets/[id]/reply/route.ts @@ -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) { diff --git a/src/app/api/tickets/[id]/route.ts b/src/app/api/tickets/[id]/route.ts index 9b2c05d1..1f11c493 100644 --- a/src/app/api/tickets/[id]/route.ts +++ b/src/app/api/tickets/[id]/route.ts @@ -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; diff --git a/src/app/api/tickets/route.ts b/src/app/api/tickets/route.ts index 76dd8c6a..11449a82 100644 --- a/src/app/api/tickets/route.ts +++ b/src/app/api/tickets/route.ts @@ -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) { diff --git a/src/app/client/page.tsx b/src/app/client/page.tsx index ce7d8240..0acdae16 100644 --- a/src/app/client/page.tsx +++ b/src/app/client/page.tsx @@ -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. diff --git a/src/components/public/logo-generator.tsx b/src/components/public/logo-generator.tsx index a654989a..ff2dfa04 100644 --- a/src/components/public/logo-generator.tsx +++ b/src/components/public/logo-generator.tsx @@ -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 - + {canSaveToSite && ( + + )}