Harden CMS security and theme contrast

This commit is contained in:
Simo committed 2026-07-11 20:27:20 +02:00
1 parent 2465ff2170
commit 4a1e1115b3
57 files changed
+1023 -231

No files matched your search

+10 -6
View File
@@ -1,6 +1,8 @@
import { redirect } from "next/navigation";
import { isStaff } from "@/lib/admin/is-staff";
import { resolveStaffUser } from "@/lib/admin/staff-user";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { siteSettings } from "@/lib/services/site-settings";
import { clientIp, rateLimit } from "@/lib/rate-limit";
@@ -25,12 +27,14 @@ export async function requireStaff(): Promise<StaffUser> {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const minRank = await getMinStaffRank();
if (!isStaff(session.user.rank, minRank)) redirect("/");
return {
id: Number(session.user.id),
rank: session.user.rank,
username: session.user.name ?? "",
};
const staff = await resolveStaffUser(session.user.id, minRank, (id) =>
prisma.user.findUnique({
where: { id },
select: { id: true, rank: true, username: true },
}),
);
if (!staff) redirect("/");
return staff;
}
/**
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { adminMutationNotice } from "@/lib/admin/notice";
describe("adminMutationNotice", () => {
it("maps a successful redirect to a safe notice", () => {
expect(adminMutationNotice({ saved: "1" })).toEqual({
tone: "ok",
label: "Saved",
message: "Changes were saved successfully.",
});
});
it("maps an error code without reflecting arbitrary query text", () => {
expect(adminMutationNotice({ error: "<script>" })).toEqual({
tone: "danger",
label: "Error",
message: "The operation could not be completed.",
});
});
it("returns null when there is no mutation result", () => {
expect(adminMutationNotice({})).toBeNull();
});
});
+26
View File
@@ -0,0 +1,26 @@
export interface AdminMutationNotice {
tone: "ok" | "danger";
label: "Saved" | "Error";
message: string;
}
export function adminMutationNotice(params: {
saved?: string;
error?: string;
}): AdminMutationNotice | null {
if (params.error) {
return {
tone: "danger",
label: "Error",
message: "The operation could not be completed.",
};
}
if (params.saved === "1") {
return {
tone: "ok",
label: "Saved",
message: "Changes were saved successfully.",
};
}
return null;
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { resolveStaffUser } from "@/lib/admin/staff-user";
describe("resolveStaffUser", () => {
it("rejects a session whose database rank has been revoked", async () => {
const findUser = async () => ({ id: 7, rank: 1, username: "Alice" });
await expect(resolveStaffUser("7", 7, findUser)).resolves.toBeNull();
});
it("returns the current database identity instead of stale JWT values", async () => {
const findUser = async () => ({ id: 7, rank: 8, username: "CurrentName" });
await expect(resolveStaffUser("7", 7, findUser)).resolves.toEqual({
id: 7,
rank: 8,
username: "CurrentName",
});
});
});
+21
View File
@@ -0,0 +1,21 @@
import { isStaff } from "@/lib/admin/is-staff";
export interface StaffUserRecord {
id: number;
rank: number;
username: string;
}
export type FindStaffUser = (id: number) => Promise<StaffUserRecord | null>;
export async function resolveStaffUser(
sessionUserId: string,
minStaffRank: number,
findUser: FindStaffUser,
): Promise<StaffUserRecord | null> {
const id = Number(sessionUserId);
if (!Number.isSafeInteger(id) || id <= 0) return null;
const user = await findUser(id);
return user && isStaff(user.rank, minStaffRank) ? user : null;
}
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { personalTokenScope } from "@/lib/auth/personal-token-scope";
describe("personalTokenScope", () => {
it("scopes token operations to the user model and owner id", () => {
expect(personalTokenScope(42)).toEqual({
tokenableId: 42n,
tokenableType: "App\\Models\\User",
});
});
});
+4 -5
View File
@@ -1,5 +1,7 @@
import { createHash, randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma";
import { personalTokenScope, USER_TOKENABLE_TYPE } from "@/lib/auth/personal-token-scope";
import { databaseUserId } from "@/lib/auth/session-user";
/**
* Bearer-token auth for the public REST API, backed by personal_access_tokens
@@ -7,8 +9,6 @@ import { prisma } from "@/lib/prisma";
* stored as the sha256 of the plaintext; the client sends the plaintext (or the
* Sanctum "{id}|{plaintext}" form) as `Authorization: Bearer …`.
*/
const TOKENABLE_TYPE = "App\\Models\\User";
function hashToken(raw: string): string {
return createHash("sha256").update(raw).digest("hex");
}
@@ -36,7 +36,7 @@ export async function bearerUserId(req: Request): Promise<number | null> {
prisma.personalAccessTokens
.update({ where: { id: row.id }, data: { lastUsedAt: new Date() }, select: { id: true } })
.catch(() => {});
return Number(row.tokenableId);
return databaseUserId(row.tokenableId);
} catch {
return null;
}
@@ -48,8 +48,7 @@ export async function issueToken(userId: number, name = "api"): Promise<string |
try {
await prisma.personalAccessTokens.create({
data: {
tokenableId: BigInt(userId),
tokenableType: TOKENABLE_TYPE,
...personalTokenScope(userId),
name: name.slice(0, 100),
token: hashToken(plaintext),
abilities: '["*"]',
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { apiUnavailable, pagination, positiveBigInt } from "@/lib/api";
describe("apiUnavailable", () => {
it("returns a no-store 503 error without exposing internal details", async () => {
const response = apiUnavailable("Account data is temporarily unavailable");
expect(response.status).toBe(503);
expect(response.headers.get("cache-control")).toBe("no-store");
await expect(response.json()).resolves.toEqual({
error: "Account data is temporarily unavailable",
});
});
});
describe("positiveBigInt", () => {
it.each(["1", "9007199254740993"])("parses positive integer id %s", (raw) => {
expect(positiveBigInt(raw)).toBe(BigInt(raw));
});
it.each([null, "", "0", "-1", "1.5", "abc"])("rejects invalid id %s", (raw) => {
expect(positiveBigInt(raw)).toBeNull();
});
});
describe("pagination", () => {
it("rejects fractional and malformed values before they reach Prisma", () => {
const params = new URLSearchParams({ page: "2.5", perPage: "10.1" });
expect(pagination(params, 20, 100)).toEqual({
page: 1,
perPage: 20,
skip: 0,
take: 20,
});
});
it("clamps valid page sizes to the configured maximum", () => {
const params = new URLSearchParams({ page: "3", perPage: "999" });
expect(pagination(params, 20, 100)).toEqual({
page: 3,
perPage: 100,
skip: 200,
take: 100,
});
});
});
+23 -5
View File
@@ -25,12 +25,30 @@ export function apiError(message: string, status = 400): NextResponse {
return apiJson({ error: message }, { status });
}
/** Safe response for temporary infrastructure failures. */
export function apiUnavailable(message = "Service temporarily unavailable"): NextResponse {
return apiError(message, 503);
}
/** Parse an unsigned, non-zero database identifier without Number precision loss. */
export function positiveBigInt(raw: string | null): bigint | null {
if (!raw || !/^\d+$/.test(raw)) return null;
const value = BigInt(raw);
return value > 0n ? value : null;
}
/** Clamp a ?page / ?perPage pair from search params. */
export function pagination(searchParams: URLSearchParams, defaultPer = 20, maxPer = 100) {
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const perPage = Math.min(
maxPer,
Math.max(1, Number(searchParams.get("perPage") ?? defaultPer) || defaultPer),
);
const requestedPage = positiveInteger(searchParams.get("page"), 1);
const requestedPerPage = positiveInteger(searchParams.get("perPage"), defaultPer);
const perPage = Math.min(maxPer, requestedPerPage);
const maxSafePage = Math.floor(Number.MAX_SAFE_INTEGER / perPage) + 1;
const page = Math.min(requestedPage, maxSafePage);
return { page, perPage, skip: (page - 1) * perPage, take: perPage };
}
function positiveInteger(raw: string | null, fallback: number): number {
if (!raw || !/^\d+$/.test(raw)) return fallback;
const value = Number(raw);
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
}
+8
View File
@@ -0,0 +1,8 @@
export const USER_TOKENABLE_TYPE = "App\\Models\\User";
export function personalTokenScope(userId: number) {
return {
tokenableId: BigInt(userId),
tokenableType: USER_TOKENABLE_TYPE,
} as const;
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { databaseUserId, sessionUserId } from "@/lib/auth/session-user";
describe("sessionUserId", () => {
it("returns a positive safe integer from a valid session id", () => {
expect(sessionUserId("42")).toBe(42);
});
it.each([undefined, null, "", "0", "-1", "1.5", "abc", Number.MAX_SAFE_INTEGER + 1])(
"rejects invalid session id %s",
(value) => {
expect(sessionUserId(value)).toBeNull();
},
);
});
describe("databaseUserId", () => {
it("converts a safe positive database id", () => {
expect(databaseUserId(42n)).toBe(42);
});
it.each([0n, -1n, BigInt(Number.MAX_SAFE_INTEGER) + 1n])(
"rejects unsafe database id %s",
(value) => {
expect(databaseUserId(value)).toBeNull();
},
);
});
+9
View File
@@ -0,0 +1,9 @@
export function sessionUserId(value: unknown): number | null {
const id = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
export function databaseUserId(value: bigint): number | null {
const id = Number(value);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { formPositiveBigInt } from "@/lib/form-data";
describe("formPositiveBigInt", () => {
it("parses a positive identifier from FormData", () => {
const formData = new FormData();
formData.set("id", "42");
expect(formPositiveBigInt(formData, "id")).toBe(42n);
});
it.each(["", "0", "-1", "1.5", "invalid"])("rejects invalid identifier %s", (value) => {
const formData = new FormData();
formData.set("id", value);
expect(formPositiveBigInt(formData, "id")).toBeNull();
});
});
+6
View File
@@ -0,0 +1,6 @@
import { positiveBigInt } from "@/lib/api";
export function formPositiveBigInt(formData: FormData, field: string): bigint | null {
const value = formData.get(field);
return typeof value === "string" ? positiveBigInt(value.trim()) : null;
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { serverErrorRecord } from "@/lib/server-log";
describe("serverErrorRecord", () => {
it("keeps operational context while redacting sensitive fields", () => {
expect(serverErrorRecord("paypal.capture_failed", new Error("gateway timeout"), {
orderId: "ORDER-123",
userId: 42,
authorization: "Bearer secret",
apiToken: "secret-token",
})).toEqual({
level: "error",
event: "paypal.capture_failed",
message: "gateway timeout",
context: {
orderId: "ORDER-123",
userId: 42,
authorization: "[REDACTED]",
apiToken: "[REDACTED]",
},
});
});
it("normalizes non-Error failures without serializing arbitrary objects", () => {
expect(serverErrorRecord("admin.update_failed", { password: "secret" })).toMatchObject({
message: "Unknown server error",
});
});
});
+39
View File
@@ -0,0 +1,39 @@
type LogScalar = string | number | boolean | null;
type LogContext = Record<string, unknown>;
const SENSITIVE_KEY = /(authorization|cookie|password|secret|token|api[-_]?key)/i;
export interface ServerErrorRecord {
level: "error";
event: string;
message: string;
context: Record<string, LogScalar>;
}
export function serverErrorRecord(
event: string,
error: unknown,
context: LogContext = {},
): ServerErrorRecord {
return {
level: "error",
event,
message: error instanceof Error ? error.message.slice(0, 500) : "Unknown server error",
context: Object.fromEntries(Object.entries(context).map(([key, value]) => [
key,
SENSITIVE_KEY.test(key) ? "[REDACTED]" : logScalar(value),
])),
};
}
export function logServerError(event: string, error: unknown, context: LogContext = {}): void {
console.error(JSON.stringify(serverErrorRecord(event, error, context)));
}
function logScalar(value: unknown): LogScalar {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? value
: value === null
? null
: "[NON_SCALAR]";
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
authorizeTopupCapture,
claimTopupDelivery,
recordCreatedTopup,
} from "@/lib/services/paypal-topup";
describe("authorizeTopupCapture", () => {
it("rejects an order created by a different user", async () => {
const findOrder = async () => ({
userId: 41,
status: "CREATED",
});
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).rejects.toMatchObject({
code: "ORDER_NOT_FOUND",
});
});
it("rejects an order that has already left the created state", async () => {
const findOrder = async () => ({
userId: 99,
status: "COMPLETED",
});
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).rejects.toMatchObject({
code: "ORDER_ALREADY_PROCESSED",
});
});
it("resumes credit delivery for a captured order without capturing it again", async () => {
const findOrder = async () => ({
userId: 99,
status: "CAPTURED_PENDING_CREDIT",
amount: 12.5,
});
await expect(authorizeTopupCapture(99, "ORDER-123", findOrder)).resolves.toMatchObject({
action: "DELIVER_CREDITS",
amount: 12.5,
});
});
});
describe("recordCreatedTopup", () => {
it("stores the PayPal order id with its owner before approval", async () => {
let saved: Parameters<typeof record>[0] | undefined;
const record = async (data: {
userId: number;
transactionId: string;
status: string;
description: string;
amount: number;
currency: string;
createdAt: Date;
updatedAt: Date;
}) => {
saved = data;
};
await recordCreatedTopup(
{ userId: 99, orderId: "ORDER-123", amount: 12.5, currency: "EUR", credits: 1250 },
record,
);
expect(saved).toMatchObject({
userId: 99,
transactionId: "ORDER-123",
status: "CREATED",
amount: 12.5,
currency: "EUR",
});
});
});
describe("claimTopupDelivery", () => {
it("rejects delivery when another request already claimed the order", async () => {
const claim = async () => ({ count: 0 });
await expect(claimTopupDelivery(claim)).rejects.toMatchObject({
code: "DELIVERY_ALREADY_CLAIMED",
});
});
it("allows delivery after atomically claiming the pending order", async () => {
const claim = async () => ({ count: 1 });
await expect(claimTopupDelivery(claim)).resolves.toBeUndefined();
});
});
+84
View File
@@ -0,0 +1,84 @@
export interface PendingTopup {
userId: number;
status: string | null;
amount?: number;
}
export type AuthorizedTopup = PendingTopup & { action: "CAPTURE" | "DELIVER_CREDITS" };
export type FindTopupOrder = (orderId: string) => Promise<PendingTopup | null>;
export interface CreatedTopupRecord {
userId: number;
transactionId: string;
status: "CREATED";
description: string;
amount: number;
currency: string;
createdAt: Date;
updatedAt: Date;
}
export type CreateTopupRecord = (data: CreatedTopupRecord) => Promise<unknown>;
export class TopupCaptureError extends Error {
constructor(public readonly code: "ORDER_NOT_FOUND" | "ORDER_ALREADY_PROCESSED") {
super(code);
}
}
export class TopupDeliveryError extends Error {
readonly code = "DELIVERY_ALREADY_CLAIMED";
constructor() {
super("DELIVERY_ALREADY_CLAIMED");
}
}
export async function claimTopupDelivery(
claim: () => Promise<{ count: number }>,
): Promise<void> {
const result = await claim();
if (result.count !== 1) throw new TopupDeliveryError();
}
export async function authorizeTopupCapture(
userId: number,
orderId: string,
findOrder: FindTopupOrder,
): Promise<AuthorizedTopup> {
const order = await findOrder(orderId);
if (!order || order.userId !== userId) {
throw new TopupCaptureError("ORDER_NOT_FOUND");
}
if (order.status === "CAPTURED_PENDING_CREDIT" && Number.isFinite(order.amount)) {
return { ...order, action: "DELIVER_CREDITS" };
}
if (order.status !== "CREATED") {
throw new TopupCaptureError("ORDER_ALREADY_PROCESSED");
}
return { ...order, action: "CAPTURE" };
}
export async function recordCreatedTopup(
topup: {
userId: number;
orderId: string;
amount: number;
currency: string;
credits: number;
},
createRecord: CreateTopupRecord,
): Promise<void> {
const now = new Date();
await createRecord({
userId: topup.userId,
transactionId: topup.orderId,
status: "CREATED",
description: `Top-up: ${topup.credits} credits`,
amount: topup.amount,
currency: topup.currency,
createdAt: now,
updatedAt: now,
});
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
describe("createOwnedTicketReply", () => {
it("does not create a reply for a ticket owned by another user", async () => {
let created = false;
const db = {
findTicket: async () => ({ id: 5n, userId: 7 }),
createReply: async () => {
created = true;
throw new Error("must not run");
},
touchTicket: async () => undefined,
};
await expect(
createOwnedTicketReply(db, { ticketId: 5n, userId: 99, content: "Help" }),
).resolves.toBeNull();
expect(created).toBe(false);
});
it("creates the reply and updates ticket activity as one operation", async () => {
let touchedAt: Date | undefined;
const db = {
findTicket: async () => ({ id: 5n, userId: 99 }),
createReply: async (data: { createdAt: Date }) => ({
id: 8n,
userId: 99,
content: "Help",
createdAt: data.createdAt,
}),
touchTicket: async (_ticketId: bigint, updatedAt: Date) => {
touchedAt = updatedAt;
},
};
const reply = await createOwnedTicketReply(db, {
ticketId: 5n,
userId: 99,
content: "Help",
});
expect(reply).toMatchObject({ id: 8n, userId: 99, content: "Help" });
expect(touchedAt).toBe(reply?.createdAt);
});
});
+31
View File
@@ -0,0 +1,31 @@
export interface TicketReplyRecord {
id: bigint;
userId: number;
content: string;
createdAt: Date | null;
}
export interface TicketReplyDb {
findTicket(ticketId: bigint): Promise<{ id: bigint; userId: number | null } | null>;
createReply(data: {
ticketId: bigint;
userId: number;
content: string;
createdAt: Date;
updatedAt: Date;
}): Promise<TicketReplyRecord>;
touchTicket(ticketId: bigint, updatedAt: Date): Promise<unknown>;
}
export async function createOwnedTicketReply(
db: TicketReplyDb,
input: { ticketId: bigint; userId: number; content: string },
): Promise<TicketReplyRecord | null> {
const ticket = await db.findTicket(input.ticketId);
if (!ticket || ticket.userId !== input.userId) return null;
const now = new Date();
const reply = await db.createReply({ ...input, createdAt: now, updatedAt: now });
await db.touchTicket(input.ticketId, now);
return reply;
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { contrastRatio, readableColor } from "@/lib/theme-contrast";
import { PRESETS } from "@/lib/theme-presets";
describe("contrastRatio", () => {
it("calculates the WCAG ratio for black on white", () => {
expect(contrastRatio("#000000", "#ffffff")).toBe(21);
});
it("returns null for unsupported color formats", () => {
expect(contrastRatio("var(--color-text)", "#ffffff")).toBeNull();
});
});
describe("built-in theme presets", () => {
it("derives WCAG-readable public foregrounds for every palette", () => {
for (const preset of Object.values(PRESETS)) {
const backgrounds = [preset.color_background, preset.color_surface];
const foregrounds = [
readableColor(preset.color_text, backgrounds),
readableColor(preset.color_text_muted, backgrounds),
readableColor(preset.color_primary, backgrounds),
readableColor(preset.color_accent, backgrounds),
];
for (const foreground of foregrounds) {
for (const background of backgrounds) {
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
}
}
const buttonText = readableColor(preset.button_text_color, [preset.button_primary_color]);
expect(contrastRatio(buttonText, preset.button_primary_color)).toBeGreaterThanOrEqual(4.5);
const primaryForeground = readableColor(preset.button_text_color, [preset.color_primary]);
expect(contrastRatio(primaryForeground, preset.color_primary)).toBeGreaterThanOrEqual(4.5);
}
});
it.each(["#22c55e", "#ef4444"])("derives readable text for default button %s", (background) => {
const foreground = readableColor("#ffffff", [background]);
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
});
});
describe("readableColor", () => {
it("keeps the preferred color when it passes on every background", () => {
expect(readableColor("#0f172a", ["#ffffff", "#f8fafc"], 4.5)).toBe("#0f172a");
});
it("replaces low-contrast golden text with a readable neutral", () => {
const color = readableColor("#f59e0b", ["#ffffff", "#f8fafc"], 4.5);
expect(color).toBe("#000000");
expect(contrastRatio(color, "#ffffff")).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(color, "#f8fafc")).toBeGreaterThanOrEqual(4.5);
});
it("chooses white text for a dark button when configured text fails", () => {
expect(readableColor("#777777", ["#111827"], 4.5)).toBe("#ffffff");
});
it("preserves unsupported custom colors instead of corrupting CSS", () => {
expect(readableColor("rgb(1, 2, 3)", ["#ffffff"], 4.5)).toBe("rgb(1, 2, 3)");
});
});
+47
View File
@@ -0,0 +1,47 @@
type Rgb = readonly [number, number, number];
export function contrastRatio(foreground: string, background: string): number | null {
const fg = parseHex(foreground);
const bg = parseHex(background);
if (!fg || !bg) return null;
const light = Math.max(relativeLuminance(fg), relativeLuminance(bg));
const dark = Math.min(relativeLuminance(fg), relativeLuminance(bg));
return (light + 0.05) / (dark + 0.05);
}
export function readableColor(
preferred: string,
backgrounds: readonly string[],
minimumRatio = 4.5,
): string {
const preferredRatios = backgrounds.map((background) => contrastRatio(preferred, background));
if (preferredRatios.some((ratio) => ratio === null)) return preferred;
if (preferredRatios.every((ratio) => (ratio as number) >= minimumRatio)) return preferred;
const candidates = ["#000000", "#ffffff"] as const;
return candidates
.map((color) => ({
color,
minimum: Math.min(...backgrounds.map((background) => contrastRatio(color, background) ?? 0)),
}))
.sort((a, b) => b.minimum - a.minimum)[0].color;
}
function parseHex(value: string): Rgb | null {
const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
if (!match) return null;
return [
Number.parseInt(match[1].slice(0, 2), 16),
Number.parseInt(match[1].slice(2, 4), 16),
Number.parseInt(match[1].slice(4, 6), 16),
];
}
function relativeLuminance(rgb: Rgb): number {
const [red, green, blue] = rgb.map((channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}