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,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",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user