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
@@ -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