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

+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([]);
});
+125
View File
@@ -0,0 +1,125 @@
import "server-only";
import { createHmac, timingSafeEqual } from "node:crypto";
import { getTranslations } from "next-intl/server";
import { env } from "@/env";
import { resolveHotelName } from "@/lib/hotel-name";
import { sendMail } from "@/lib/services/email";
// Stateless email verification with a time-limited HMAC token.
//
// Token format: `{issuedAtUnix}.{hmacHex}` where
// hmac = HMAC-SHA256(secret, `${email}|${issuedAt}`)
// Tokens expire after TOKEN_TTL_MS (24h). Legacy forever-valid digests
// (bare 64-char hex) are rejected.
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
/** Secret mixed into the HMAC. Requires at least one of APP_KEY or AUTH_SECRET. */
function verifySecret(): string {
const secret = env.APP_KEY || env.AUTH_SECRET;
if (!secret)
throw new Error(
"APP_KEY or AUTH_SECRET must be set for email verification",
);
return secret;
}
function sign(email: string, issuedAt: number): string {
return createHmac("sha256", verifySecret())
.update(`${email}|${issuedAt}`)
.digest("hex");
}
/** Compute a fresh verification token for an email (lowercased + trimmed). */
export async function verificationToken(email: string): Promise<string> {
const normalised = email.trim().toLowerCase();
const issuedAt = Math.floor(Date.now() / 1000);
return `${issuedAt}.${sign(normalised, issuedAt)}`;
}
/**
* Constant-time check that `token` matches a non-expired HMAC for `email`.
* Returns false on format/expiry/signature mismatch rather than throwing.
*/
export async function isValidVerificationToken(
email: string,
token: string,
): Promise<boolean> {
if (!email || !token) return false;
const normalised = email.trim().toLowerCase();
const match = /^(\d+)\.([a-f0-9]{64})$/i.exec(token.trim());
if (!match) return false; // also rejects legacy forever-valid digests
const issuedAt = Number(match[1]);
const sig = match[2]?.toLowerCase() ?? "";
if (!Number.isFinite(issuedAt) || issuedAt <= 0) return false;
const ageMs = Date.now() - issuedAt * 1000;
if (ageMs < 0 || ageMs > TOKEN_TTL_MS) return false;
const expected = sign(normalised, issuedAt);
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(sig, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Build the verification link + email and send it. No-ops gracefully when SMTP
* is unconfigured (sendMail returns false).
*/
export async function sendVerification(email: string): Promise<boolean> {
const normalised = email.trim().toLowerCase();
if (!normalised) return false;
const token = await verificationToken(normalised);
const base = env.APP_URL.replace(/\/+$/, "");
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
const hotelName = await resolveHotelName();
let subject = `Verify your email · ${hotelName}`;
let heading = "Verify your email";
let body = `Welcome to ${hotelName}! Confirm this email address to finish setting up your account.`;
let button = "Verify email";
let fallback =
"If the button doesn't work, paste this link into your browser:";
try {
const t = await getTranslations("emails.verify");
subject = t("subject", { hotel: hotelName });
heading = t("heading");
body = t("body", { hotel: hotelName });
button = t("button");
fallback = t("fallback");
} catch {
/* messages missing — keep English defaults */
}
const html = `
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
<h2 style="margin:0 0 0.5rem">${escapeHtml(heading)}</h2>
<p>${escapeHtml(body)}</p>
<p style="margin:1.25rem 0">
<a href="${link}"
style="display:inline-block;padding:0.6rem 1.2rem;border-radius:8px;background:#eeb425;color:#1a1a2e;font-weight:700;text-decoration:none">
${escapeHtml(button)}
</a>
</p>
<p style="color:#64748b;font-size:0.875rem">${escapeHtml(fallback)}</p>
<p style="color:#64748b;font-size:0.875rem;word-break:break-all">${link}</p>
</div>
`.trim();
return sendMail(normalised, subject, html);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
+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),