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
@@ -16,7 +16,7 @@ export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["articles:write"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
if (!(await rateLimit(`article-comment:${uid}`, 10, 60_000)).ok) {
|
||||
|
||||
@@ -303,8 +303,8 @@ async function loadRarityViewer(
|
||||
export async function GET(req: Request) {
|
||||
await connection();
|
||||
try {
|
||||
let userId: number | null = await bearerUserId(req);
|
||||
if (!userId) {
|
||||
let userId: number | null = await bearerUserId(req, ["badges:read"]);
|
||||
if (!req.headers.has("authorization")) {
|
||||
const session = await auth();
|
||||
userId = session?.user?.id ? Number(session.user.id) : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
|
||||
vi.mock("node:fs/promises", () => ({ readFile: vi.fn() }));
|
||||
vi.mock("@/lib/media-storage", () => ({
|
||||
MEDIA_ROOT: path.resolve("storage/test-media"),
|
||||
resolveMediaPath: (name: string) => path.resolve("storage/test-media", name),
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue(Buffer.from("fixture"));
|
||||
});
|
||||
|
||||
async function get(segments: string[]) {
|
||||
return GET(new Request("http://localhost/api/media/test"), {
|
||||
params: Promise.resolve({ path: segments }),
|
||||
});
|
||||
}
|
||||
function secureHeaders(response: Response) {
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(response.headers.get("content-security-policy")).toBe(
|
||||
"default-src 'none'; sandbox",
|
||||
);
|
||||
}
|
||||
|
||||
it.each([
|
||||
["photo.png", "image/png"],
|
||||
["favicon.ico", "image/x-icon"],
|
||||
])("serves %s as an image with protective headers", async (name, mime) => {
|
||||
const response = await get([name]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe(mime);
|
||||
expect(response.headers.get("content-disposition")).toBeNull();
|
||||
secureHeaders(response);
|
||||
});
|
||||
|
||||
it("forces existing SVG files to download", async () => {
|
||||
const response = await get(["favicon", "old.SVG"]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-disposition")).toBe("attachment");
|
||||
secureHeaders(response);
|
||||
});
|
||||
|
||||
it.each([["..", "secret.png"], ["file.html"], ["bad\\file.png"]])(
|
||||
"secures forbidden path %j",
|
||||
async (...segments) => {
|
||||
const response = await get(segments);
|
||||
expect(response.status).toBe(403);
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
secureHeaders(response);
|
||||
},
|
||||
);
|
||||
|
||||
it("secures missing-file responses", async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
const response = await get(["missing.png"]);
|
||||
expect(response.status).toBe(404);
|
||||
secureHeaders(response);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ENOENT", 404],
|
||||
["EACCES", 500],
|
||||
])(
|
||||
"handles %s while reading without leaking local paths",
|
||||
async (code, status) => {
|
||||
vi.mocked(readFile).mockRejectedValue(
|
||||
Object.assign(new Error("private/server/path"), { code }),
|
||||
);
|
||||
const response = await get(["image.png"]);
|
||||
expect(response.status).toBe(status);
|
||||
expect(await response.text()).not.toContain("private");
|
||||
secureHeaders(response);
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,7 @@ import { db, RadioListenerPoints } from "@/lib/db";
|
||||
// radio_listener_points.points rows for that user_id.
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["radio:read"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
try {
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function GET(_req: Request) {
|
||||
|
||||
// Post a new radio shout as the Bearer-authed user into radio_shouts.
|
||||
export async function POST(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["radio:write"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
if (!(await rateLimit(`radio-shout:${uid}`, 10, 60_000)).ok) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["tickets:write"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
if (!(await rateLimit(`api-ticket-reply:${uid}`, 10, 60_000)).ok) {
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["tickets:read"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
// GET /api/tickets — the authed user's tickets (newest first).
|
||||
export async function GET(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["tickets:read"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
try {
|
||||
@@ -43,7 +43,7 @@ export async function GET(req: Request) {
|
||||
|
||||
// POST /api/tickets — open a new ticket ({ title, content, categoryId? }).
|
||||
export async function POST(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
const uid = await bearerUserId(req, ["tickets:write"]);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
if (!(await rateLimit(`api-ticket:${uid}`, 5, 60_000)).ok) {
|
||||
|
||||
Reference in new issue
Block a user