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

+8
View File
@@ -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
+26
View File
@@ -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.
@@ -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.
+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";
+21 -44
View File
@@ -4,53 +4,33 @@ import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteSetting } from "@/lib/db";
import { validateSiteImageUpload } from "@/lib/images/site-image-upload";
import { logger } from "@/lib/logger";
import { resolveMediaPath } from "@/lib/media-storage";
import { PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
const FAVICON_DIR = resolveMediaPath("favicon");
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
const ALLOWED = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/x-icon",
"image/svg+xml",
];
export async function saveFavicon(
formData: FormData,
): Promise<{ success: boolean; url?: string; error?: string }> {
await requirePermission(PERMS.SETTINGS_EDIT);
try {
const file = formData.get("file") as File | null;
if (!file || file.size === 0)
return { success: false, error: "No file provided" };
if (file.size > MAX_SIZE)
return { success: false, error: "File too large (max 2MB)" };
if (!ALLOWED.includes(file.type))
return {
success: false,
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
};
const mimeExt: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/x-icon": "ico",
"image/svg+xml": "svg",
};
const ext = mimeExt[file.type] ?? "png";
const upload = await validateSiteImageUpload(
formData instanceof FormData ? formData.get("file") : null,
{ allowIcon: true },
);
if (!upload.success) return upload;
const ext = upload.extension;
const filename = `favicon-${Date.now()}.${ext}`;
const baseDir = FAVICON_DIR;
const baseDir = resolveMediaPath("favicon");
const filePath = path.resolve(baseDir, filename);
if (!filePath.startsWith(baseDir + path.sep)) {
return { success: false, error: "Invalid path" };
}
const buffer = Buffer.from(await file.arrayBuffer());
const buffer = upload.bytes;
// eslint-disable-next-line security/detect-non-literal-fs-filename
await mkdir(baseDir, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename
@@ -85,11 +65,9 @@ export async function saveFavicon(
revalidatePath("/admin/favicon");
return { success: true, url };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
} catch {
logger.error("Site favicon update failed", { module: "site-images" });
return { success: false, error: "Could not update site favicon" };
}
}
@@ -97,10 +75,11 @@ export async function deleteFavicon(): Promise<{
success: boolean;
error?: string;
}> {
await requirePermission(PERMS.SETTINGS_EDIT);
try {
const oldUrl = await siteSettings.get("cms_favicon");
if (oldUrl?.startsWith("/api/media/favicon/")) {
const baseDir = FAVICON_DIR;
const baseDir = resolveMediaPath("favicon");
const oldName = oldUrl.replace("/api/media/favicon/", "");
if (!oldName.includes("..") && !oldName.includes("/")) {
const oldPath = path.resolve(baseDir, oldName);
@@ -127,10 +106,8 @@ export async function deleteFavicon(): Promise<{
revalidatePath("/admin/favicon");
return { success: true };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
} catch {
logger.error("Site favicon update failed", { module: "site-images" });
return { success: false, error: "Could not update site favicon" };
}
}
+15 -22
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: "/",
+281
View File
@@ -0,0 +1,281 @@
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard";
import { db } from "@/lib/db";
import { logger } from "@/lib/logger";
import { PERMS } from "@/lib/permission-slugs";
import { siteSettings } from "@/lib/services/site-settings";
import { deleteFavicon, saveFavicon } from "./save-favicon";
import { saveLogo } from "./save-logo";
const database = vi.hoisted(() => ({
upsert: vi.fn(),
values: vi.fn(),
where: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => import("@/lib/permission-slugs"));
vi.mock("@/lib/db", () => ({
db: {
insert: vi.fn(() => ({ values: database.values })),
delete: vi.fn(() => ({ where: database.where })),
},
WebsiteSetting: { key: "key" },
}));
vi.mock("@/lib/media-storage", () => ({
resolveMediaPath: (name: string) => path.resolve("storage/test-media", name),
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { get: vi.fn(), reload: vi.fn() },
}));
vi.mock("node:fs/promises", () => ({
mkdir: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requirePermission).mockResolvedValue({
id: 1,
rank: 7,
username: "editor",
});
database.values.mockReturnValue({ onDuplicateKeyUpdate: database.upsert });
});
function form(file: File | string) {
const data = new FormData();
data.set("file", file);
return data;
}
function noMutation() {
expect(mkdir).not.toHaveBeenCalled();
expect(writeFile).not.toHaveBeenCalled();
expect(unlink).not.toHaveBeenCalled();
expect(db.insert).not.toHaveBeenCalled();
expect(db.delete).not.toHaveBeenCalled();
expect(siteSettings.reload).not.toHaveBeenCalled();
}
for (const [name, save] of [
["logo", saveLogo],
["favicon", saveFavicon],
] as const) {
describe(name, () => {
it.each(["anonymous", "settings viewer"])(
"denies %s before reading the upload or settings",
async () => {
const denied = new Error("denied");
vi.mocked(requirePermission).mockRejectedValue(denied);
const data = new FormData();
const read = vi.spyOn(data, "get");
await expect(save(data)).rejects.toBe(denied);
expect(requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
expect(read).not.toHaveBeenCalled();
expect(siteSettings.get).not.toHaveBeenCalled();
noMutation();
},
);
it.each([
[
"SVG",
"image/svg+xml",
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"/>',
],
["SVG disguised as PNG", "image/png", '<svg onload="alert(1)"/>'],
[
"HTML disguised as PNG",
"image/png",
"<!doctype html><script>alert(1)</script>",
],
["unknown MIME", "application/octet-stream", "not an image"],
])(
"rejects %s without changing files or settings",
async (_name, type, content) => {
const result = await save(
form(new File([content], "upload.png", { type })),
);
expect(result.success).toBe(false);
noMutation();
},
);
it("rejects a form string as a file", async () => {
expect((await save(form("image/png"))).success).toBe(false);
noMutation();
});
it("rejects files above 2 MiB before reading their contents", async () => {
const file = new File(
[new Uint8Array(2 * 1024 * 1024 + 1)],
"large.png",
{ type: "image/png" },
);
const data = form(file);
const read = vi.spyOn(data.get("file") as File, "arrayBuffer");
expect((await save(data)).success).toBe(false);
expect(read).not.toHaveBeenCalled();
noMutation();
});
it.each(["png", "jpeg", "gif", "webp"] as const)(
"keeps legitimate %s uploads working",
async (format) => {
const image = await sharp({
create: { width: 2, height: 2, channels: 4, background: "#ff0000" },
})
.toFormat(format)
.toBuffer();
const result = await save(
form(
new File([new Uint8Array(image)], "upload", {
type: `image/${format}`,
}),
),
);
expect(requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
expect(result.success).toBe(true);
expect(result.url).toMatch(
new RegExp(
`^/api/media/${name}/[^/]+\\.${format === "jpeg" ? "jpg" : format}$`,
),
);
expect(writeFile).toHaveBeenCalledWith(expect.any(String), image);
expect(database.values).toHaveBeenCalledWith(
expect.objectContaining({ key: `cms_${name}`, value: result.url }),
);
},
);
it("rejects a raster image whose bytes disagree with its MIME", async () => {
const image = await sharp({
create: { width: 1, height: 1, channels: 4, background: "#000" },
})
.png()
.toBuffer();
expect(
(
await save(
form(
new File([new Uint8Array(image)], "wrong.gif", {
type: "image/gif",
}),
),
)
).success,
).toBe(false);
noMutation();
});
it("does not reveal filesystem errors to the caller", async () => {
const image = await sharp({
create: { width: 1, height: 1, channels: 4, background: "#000" },
})
.png()
.toBuffer();
vi.mocked(writeFile).mockRejectedValue(
new Error("EACCES /private/media/secret.png"),
);
const result = await save(
form(
new File([new Uint8Array(image)], "logo.png", { type: "image/png" }),
),
);
expect(result.success).toBe(false);
expect(result.error).not.toMatch(/EACCES|private|secret/);
expect(logger.error).toHaveBeenCalled();
});
});
}
it("denies favicon deletion before reading settings or touching files", async () => {
const denied = new Error("denied");
vi.mocked(requirePermission).mockRejectedValue(denied);
await expect(deleteFavicon()).rejects.toBe(denied);
expect(requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
expect(siteSettings.get).not.toHaveBeenCalled();
noMutation();
});
it.each(["image/x-icon", "image/vnd.microsoft.icon"])(
"accepts a valid ICO favicon with MIME %s",
async (type) => {
const png = await sharp({
create: { width: 1, height: 1, channels: 4, background: "#000" },
})
.png()
.toBuffer();
const directory = Buffer.alloc(22);
directory.writeUInt16LE(1, 2);
directory.writeUInt16LE(1, 4);
directory[6] = 1;
directory[7] = 1;
directory.writeUInt16LE(1, 10);
directory.writeUInt16LE(32, 12);
directory.writeUInt32LE(png.length, 14);
directory.writeUInt32LE(22, 18);
const bytes = Buffer.concat([directory, png]);
const result = await saveFavicon(
form(new File([new Uint8Array(bytes)], "icon.ico", { type })),
);
expect(result.success).toBe(true);
expect(writeFile).toHaveBeenCalledWith(
expect.stringMatching(/\.ico$/),
bytes,
);
},
);
it("rejects active content behind a forged ICO header", async () => {
const bytes = Buffer.concat([
Buffer.from([0, 0, 1, 0, 1, 0]),
Buffer.from('<svg onload="alert(1)"/>'),
]);
expect(
(
await saveFavicon(
form(
new File([new Uint8Array(bytes)], "icon.ico", {
type: "image/x-icon",
}),
),
)
).success,
).toBe(false);
noMutation();
});
it("rejects a truncated image with a valid PNG signature", async () => {
const bytes = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
expect(
(
await saveLogo(
form(new File([bytes], "image.png", { type: "image/png" })),
)
).success,
).toBe(false);
noMutation();
});
it("rejects disguised SVG before invoking any image decoder", async () => {
const metadata = vi.spyOn(sharp.prototype, "metadata");
try {
const file = new File(
['<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'],
"logo.png",
{ type: "image/png" },
);
expect((await saveLogo(form(file))).success).toBe(false);
expect(metadata).not.toHaveBeenCalled();
noMutation();
} finally {
metadata.mockRestore();
}
});
+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.
+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))
);
}
+188
View File
@@ -0,0 +1,188 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
abilities: '["*"]',
queries: [] as string[],
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { drizzle } = await import("drizzle-orm/mysql-proxy");
const db = drizzle(async (sql) => {
state.queries.push(sql);
if (
sql.startsWith("select ") &&
sql.includes(" from `personal_access_tokens`")
) {
const token: Record<string, unknown> = {
id: "9",
tokenable_id: "42",
tokenable_type: "App\\Models\\User",
abilities: state.abilities,
};
const columns = sql
.slice(7, sql.indexOf(" from "))
.split(", ")
.map((column) => column.replaceAll("`", ""));
return { rows: [columns.map((column) => token[column])] };
}
return { rows: [] };
});
return {
...schema,
db: Object.assign(db, { execute: async () => [[{ cnt: 0n }], []] }),
};
});
vi.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: "77" } }) }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
vi.mock("next/server", async (original) => ({
...(await original<typeof import("next/server")>()),
connection: async () => {},
}));
vi.mock("@/lib/redis-cache", () => ({
apiCacheKey: (key: string) => key,
cacheSafe: (value: unknown) => value,
redisCache: async () => ({
badgeStats: [],
totalBadges: { entries: [], totalPlayers: 0 },
achievementLevel: { entries: [], totalPlayers: 0 },
rarity: {},
}),
}));
import { POST as articleComment } from "@/app/api/articles/[slug]/comment/route";
import { GET as badgeLeaderboard } from "@/app/api/badges/leaderboard/route";
import { GET as radioPoints } from "@/app/api/radio/points/route";
import { POST as radioShout } from "@/app/api/radio/shouts/route";
import { POST as ticketReply } from "@/app/api/tickets/[id]/reply/route";
import { GET as ticketGet } from "@/app/api/tickets/[id]/route";
import {
GET as ticketsGet,
POST as ticketsPost,
} from "@/app/api/tickets/route";
function request(method: string, bearer = true) {
return new Request("https://hotel.test/api/test", {
method,
headers: bearer
? {
authorization: "Bearer test-token",
"content-type": "application/json",
}
: {},
...(method === "POST" ? { body: "{}" } : {}),
});
}
const protectedRoutes = [
{
name: "GET tickets",
scope: "tickets:read",
method: "GET",
run: ticketsGet,
allowedStatus: 200,
},
{
name: "POST tickets",
scope: "tickets:write",
method: "POST",
run: ticketsPost,
allowedStatus: 400,
},
{
name: "GET ticket detail",
scope: "tickets:read",
method: "GET",
run: (req: Request) =>
ticketGet(req, { params: Promise.resolve({ id: "0" }) }),
allowedStatus: 422,
},
{
name: "POST ticket reply",
scope: "tickets:write",
method: "POST",
run: (req: Request) =>
ticketReply(req, { params: Promise.resolve({ id: "0" }) }),
allowedStatus: 422,
},
{
name: "POST article comment",
scope: "articles:write",
method: "POST",
run: (req: Request) =>
articleComment(req, { params: Promise.resolve({ slug: "article" }) }),
allowedStatus: 422,
},
{
name: "GET radio points",
scope: "radio:read",
method: "GET",
run: radioPoints,
allowedStatus: 200,
},
{
name: "POST radio shout",
scope: "radio:write",
method: "POST",
run: radioShout,
allowedStatus: 422,
},
];
beforeEach(() => {
state.abilities = '["*"]';
state.queries = [];
});
describe("API endpoint token scope boundaries", () => {
it.each(protectedRoutes)(
"$name rejects unrelated scopes before accessing endpoint data",
async ({ scope, method, run }) => {
state.abilities = JSON.stringify([
scope.startsWith("tickets:") ? "radio:read" : "tickets:read",
]);
const response = await run(request(method));
expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: "Unauthorized" });
expect(
state.queries.every((sql) => sql.includes("personal_access_tokens")),
).toBe(true);
},
);
it.each(protectedRoutes)(
"$name accepts its documented scope",
async ({ scope, method, run, allowedStatus }) => {
state.abilities = JSON.stringify([scope]);
expect((await run(request(method))).status).toBe(allowedStatus);
},
);
it.each(protectedRoutes)(
"$name preserves existing wildcard tokens",
async ({ method, run, allowedStatus }) => {
expect((await run(request(method))).status).toBe(allowedStatus);
},
);
it("does not let a read-only ticket token create a ticket", async () => {
state.abilities = '["tickets:read"]';
expect((await ticketsPost(request("POST"))).status).toBe(401);
});
it("does not let a read-only radio token post a shout", async () => {
state.abilities = '["radio:read"]';
expect((await radioShout(request("POST"))).status).toBe(401);
});
it("does not use session cookies to bypass a denied bearer scope on the public leaderboard", async () => {
state.abilities = '["tickets:read"]';
const response = await badgeLeaderboard(request("GET"));
expect((await response.json()).viewerUserId).toBe(0);
});
it("personalizes the leaderboard only for the badges scope", async () => {
state.abilities = '["badges:read"]';
expect(
(await (await badgeLeaderboard(request("GET"))).json()).viewerUserId,
).toBe(42);
});
it("preserves session-only leaderboard personalization without a bearer header", async () => {
expect(
(await (await badgeLeaderboard(request("GET", false))).json())
.viewerUserId,
).toBe(77);
});
});
+138
View File
@@ -0,0 +1,138 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
token: {
id: "9",
tokenable_id: "42",
tokenable_type: "App\\Models\\User",
abilities: '["*"]',
} as Record<string, unknown>,
found: true,
fail: false,
queries: [] as { sql: string; params: unknown[] }[],
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { drizzle } = await import("drizzle-orm/mysql-proxy");
return {
...schema,
db: drizzle(async (sql, params) => {
state.queries.push({ sql, params });
if (state.fail) throw Error("database failure containing private data");
if (!sql.startsWith("select ")) return { rows: [] };
const columns = sql
.slice(7, sql.indexOf(" from "))
.split(", ")
.map((column) => column.replaceAll("`", ""));
return {
rows: state.found ? [columns.map((column) => state.token[column])] : [],
};
}),
};
});
import { bearerUserId } from "./api-auth";
const request = (token = "test-token") =>
new Request("https://hotel.test/api/tickets", {
headers: { authorization: `Bearer ${token}` },
});
beforeEach(() => {
state.token = {
id: "9",
tokenable_id: "42",
tokenable_type: "App\\Models\\User",
abilities: '["*"]',
};
state.found = true;
state.fail = false;
state.queries = [];
});
describe("personal bearer token authorization", () => {
it.each(["test-token", "9|test-token"])(
"preserves full-access token format %s",
async (value) => {
expect(await bearerUserId(request(value))).toBe(42);
expect(state.queries[0].params).toContain(
createHash("sha256").update("test-token").digest("hex"),
);
expect(state.queries[0].params).not.toContain("test-token");
},
);
it("requires the exact user owner model and an unexpired token in the query", async () => {
await bearerUserId(request());
expect(state.queries[0].sql).toContain("`tokenable_type` = ?");
expect(state.queries[0].params).toContain("App\\Models\\User");
expect(state.queries[0].sql).toContain("`expires_at` is null");
expect(state.queries[0].sql).toContain("`expires_at` > ?");
});
it.each(["App\\Models\\Admin", "app\\models\\user", "App\\User", ""])(
"rejects a token belonging to %s before recording use",
async (owner) => {
state.token.tokenable_type = owner;
expect(await bearerUserId(request())).toBeNull();
expect(
state.queries.some((query) => query.sql.startsWith("update ")),
).toBe(false);
},
);
it.each([
null,
"",
"not-json",
'"*"',
"{}",
"[]",
"[null]",
'["*",false]',
'["tickets:read",""]',
])("denies malformed or empty abilities %s", async (abilities) => {
state.token.abilities = abilities;
expect(await bearerUserId(request())).toBeNull();
expect(state.queries.some((query) => query.sql.startsWith("update "))).toBe(
false,
);
});
it("does not treat scoped tokens as unrestricted when the caller omits required abilities", async () => {
state.token.abilities = '["tickets:read"]';
expect(await bearerUserId(request())).toBeNull();
expect(await bearerUserId(request(), [])).toBeNull();
});
it("allows only explicitly granted domains and operations", async () => {
state.token.abilities = '["tickets:read","radio:read"]';
expect(await bearerUserId(request(), ["tickets:read"])).toBe(42);
expect(await bearerUserId(request(), ["tickets:write"])).toBeNull();
expect(await bearerUserId(request(), ["articles:write"])).toBeNull();
expect(
await bearerUserId(request(), ["tickets:read", "tickets:write"]),
).toBeNull();
});
it("retains wildcard compatibility for explicitly scoped endpoints", async () => {
expect(await bearerUserId(request(), ["tickets:write", "radio:read"])).toBe(
42,
);
});
it("does not interpret domain wildcards or whitespace as permissions", async () => {
state.token.abilities = '["tickets:*", " tickets:read"]';
expect(await bearerUserId(request(), ["tickets:read"])).toBeNull();
});
it.each(["0", "9007199254740993"])(
"rejects invalid user id %s without recording use",
async (id) => {
state.token.tokenable_id = id;
expect(await bearerUserId(request(), ["tickets:read"])).toBeNull();
expect(
state.queries.some((query) => query.sql.startsWith("update ")),
).toBe(false);
},
);
it("fails closed on database errors and absent tokens", async () => {
state.fail = true;
expect(await bearerUserId(request(), ["tickets:read"])).toBeNull();
state.fail = false;
state.found = false;
expect(await bearerUserId(request(), ["tickets:read"])).toBeNull();
});
});
+179
View File
@@ -0,0 +1,179 @@
import { randomUUID } from "node:crypto";
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
headers: new Headers(),
headersUnavailable: false,
session: null as { user: { id: string } } | null,
issueSsoTicket: vi.fn(),
insert: vi.fn(),
recordRequest: vi.fn(),
isIpBlacklisted: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: async () => {
if (state.headersUnavailable) throw Error("No request context");
return state.headers;
},
cookies: vi.fn(),
}));
vi.mock("@/lib/redis", () => ({ redis: null }));
vi.mock("@/lib/logger", () => ({ logger: { warn: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/auth", () => ({ auth: async () => state.session }));
vi.mock("@/lib/auth/sso-ticket", () => ({
issueSsoTicket: state.issueSsoTicket,
}));
vi.mock("@/lib/hotel-name", () => ({
resolveHotelName: async () => "Integration",
}));
vi.mock("@/lib/cache", () => ({ cached: async () => 0 }));
vi.mock("@/app/client/client-view", () => ({ ClientView: () => null }));
vi.mock("next-auth/jwt", () => ({ getToken: async () => null }));
vi.mock("@/lib/services/abuse-guard", () => ({
recordRequest: state.recordRequest,
isIpBlacklisted: state.isIpBlacklisted,
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: {
getBool: async () => false,
get: async () => "/nitro-client/",
},
}));
vi.mock("@/lib/db", () => ({
db: { insert: () => ({ values: state.insert }) },
StaffActivities: {},
Ban: {},
}));
import ClientPage from "@/app/client/page";
import { proxy } from "@/proxy";
import { enforceSiteAccess } from "./access-guard";
import { extractClientIpAsync } from "./foundation/security";
import { clientIp, rateLimit } from "./rate-limit";
import { logStaffActivity } from "./services/staff-activity";
beforeEach(() => {
vi.clearAllMocks();
state.headers = new Headers({ "x-pathname": "/me" });
state.headersUnavailable = false;
state.session = null;
state.issueSsoTicket.mockResolvedValue("integration-ticket");
state.insert.mockResolvedValue([{ insertId: 1 }]);
state.recordRequest.mockResolvedValue(undefined);
state.isIpBlacklisted.mockResolvedValue(false);
});
describe("client IP security consumers", () => {
it.each([
{
headers: {
"x-real-client-ip": "198.51.100.99",
"x-forwarded-for": "192.0.2.10, 192.0.2.20",
},
expected: "192.0.2.10",
},
{
headers: {
"x-real-client-ip": "198.51.100.99",
"cf-connecting-ip": "2001:DB8:0:0::1",
"x-forwarded-for": "192.0.2.10",
},
expected: "2001:db8::1",
},
{ headers: { "x-real-client-ip": "198.51.100.99" }, expected: "0.0.0.0" },
{
headers: {
"cf-connecting-ip": "",
"x-forwarded-for": "malformed, 192.0.2.10",
"x-real-ip": "192.0.2.30",
},
expected: "192.0.2.30",
},
{
headers: {
"cf-connecting-ip": "invalid",
"x-forwarded-for": "",
"x-real-ip": "192.0.2.1:8080",
},
expected: "0.0.0.0",
},
])(
"uses the same validated address for rate limiting, security, access and staff audits: $expected",
async ({ headers, expected }) => {
for (const [name, value] of Object.entries(headers))
state.headers.set(name, value);
expect(await clientIp()).toBe(expected);
expect(await extractClientIpAsync()).toBe(expected);
await enforceSiteAccess();
expect(state.recordRequest).toHaveBeenCalledWith(expected);
expect(state.isIpBlacklisted).toHaveBeenCalledWith(expected);
await logStaffActivity({
staffId: 7,
action: "test",
description: "IP regression",
});
expect(state.insert).toHaveBeenCalledWith(
expect.objectContaining({ ipAddress: expected }),
);
},
);
it("uses the normalized forwarded address for the game ticket", async () => {
state.session = { user: { id: "7" } };
state.headers.set("x-real-client-ip", "198.51.100.99");
state.headers.set("x-forwarded-for", "192.0.2.10, 192.0.2.20");
await ClientPage();
expect(state.issueSsoTicket).toHaveBeenCalledWith(
7,
"Integration",
"192.0.2.10",
);
});
it("cannot obtain another API rate-limit bucket by changing the derived header", async () => {
state.headers.set("x-forwarded-for", "192.0.2.10");
state.headers.set("x-real-client-ip", "198.51.100.1");
const key = `api-ip-regression:${randomUUID()}`;
expect((await rateLimit(`${key}:${await clientIp()}`, 1, 60_000)).ok).toBe(
true,
);
state.headers.set("x-real-client-ip", "198.51.100.2");
expect((await rateLimit(`${key}:${await clientIp()}`, 1, 60_000)).ok).toBe(
false,
);
});
it("uses the unknown address when request headers are unavailable", async () => {
state.headersUnavailable = true;
expect(await clientIp()).toBe("0.0.0.0");
expect(await extractClientIpAsync()).toBe("0.0.0.0");
await logStaffActivity({
staffId: 7,
action: "test",
description: "Missing request",
});
expect(state.insert).toHaveBeenCalledWith(
expect.objectContaining({ ipAddress: "0.0.0.0" }),
);
});
it.each<Record<string, string>>([{}, { "x-forwarded-for": "192.0.2.10" }])(
"removes the incoming derived header from requests forwarded by the proxy",
async (forwarded) => {
const request = new NextRequest("http://localhost:3000/news", {
headers: { ...forwarded, "x-real-client-ip": "198.51.100.99" },
});
const response = await proxy(request);
expect(
response.headers.get("x-middleware-request-x-real-client-ip"),
).toBeNull();
expect(
response.headers.get("x-middleware-override-headers"),
).not.toContain("x-real-client-ip");
expect(response.headers.get("x-middleware-request-x-pathname")).toBe(
"/news",
);
},
);
});
+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;
}
}
+106
View File
@@ -0,0 +1,106 @@
import "server-only";
import sharp from "sharp";
const MAX_BYTES = 2 * 1024 * 1024;
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const FORMATS = new Map([
["image/png", "png"],
["image/jpeg", "jpeg"],
["image/gif", "gif"],
["image/webp", "webp"],
["image/x-icon", "ico"],
["image/vnd.microsoft.icon", "ico"],
]);
type ValidatedUpload =
| { success: true; bytes: Buffer; extension: string }
| { success: false; error: string };
async function validRaster(bytes: Buffer, format: string) {
const signatureMatches =
(format === "png" && bytes.subarray(0, 8).equals(PNG_SIGNATURE)) ||
(format === "jpeg" &&
bytes[0] === 0xff &&
bytes[1] === 0xd8 &&
bytes[2] === 0xff) ||
(format === "gif" &&
["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6))) ||
(format === "webp" &&
bytes.toString("ascii", 0, 4) === "RIFF" &&
bytes.toString("ascii", 8, 12) === "WEBP");
if (!signatureMatches) return false;
const image = sharp(bytes, {
failOn: "warning",
limitInputPixels: 16 * 1024 * 1024,
animated: true,
});
const metadata = await image.metadata();
if (metadata.format !== format) return false;
await image.stats();
return true;
}
async function validIcon(bytes: Buffer) {
if (bytes.length < 22 || bytes.readUInt32LE(0) !== 0x00010000) return false;
const count = bytes.readUInt16LE(4);
const directoryEnd = 6 + count * 16;
if (!count || directoryEnd > bytes.length) return false;
for (let i = 0; i < count; i++) {
const entry = 6 + i * 16;
const length = bytes.readUInt32LE(entry + 8);
const offset = bytes.readUInt32LE(entry + 12);
if (offset < directoryEnd || length < 12 || offset + length > bytes.length)
return false;
const frame = bytes.subarray(offset, offset + length);
if (frame.subarray(0, 8).equals(PNG_SIGNATURE)) {
if (!(await validRaster(frame, "png"))) return false;
continue;
}
// Classic ICO frames contain a DIB header rather than a standalone BMP.
const headerSize = frame.readUInt32LE(0);
if (
![12, 40, 52, 56, 108, 124].includes(headerSize) ||
length <= headerSize
)
return false;
const width = bytes[entry] || 256;
const height = bytes[entry + 1] || 256;
const core = headerSize === 12;
if (
(core ? frame.readUInt16LE(4) : frame.readInt32LE(4)) !== width ||
(core ? frame.readUInt16LE(6) : frame.readInt32LE(8)) !== height * 2 ||
frame.readUInt16LE(core ? 8 : 12) !== 1 ||
![1, 4, 8, 16, 24, 32].includes(frame.readUInt16LE(core ? 10 : 14))
)
return false;
}
return true;
}
export async function validateSiteImageUpload(
value: unknown,
{ allowIcon = false }: { allowIcon?: boolean } = {},
): Promise<ValidatedUpload> {
if (!(value instanceof File) || value.size === 0)
return { success: false, error: "No file provided" };
if (value.size > MAX_BYTES)
return { success: false, error: "File too large (max 2MB)" };
const format = FORMATS.get(value.type);
if (!format || (format === "ico" && !allowIcon))
return { success: false, error: "Unsupported image type" };
try {
const bytes = Buffer.from(await value.arrayBuffer());
const valid =
format === "ico"
? await validIcon(bytes)
: await validRaster(bytes, format);
if (!valid) return { success: false, error: "Invalid image file" };
return {
success: true,
bytes,
extension: format === "jpeg" ? "jpg" : format,
};
} catch {
return { success: false, error: "Invalid image file" };
}
}
+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 } });