fix(security): authorize site uploads and harden tokens, media and request identity
This commit is contained in:
1 parent
52f6d1491f
commit
8abfe352ef
70 files changed
+1609
-204
No files matched your search
@@ -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
@@ -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([]);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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))
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(" ");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user