mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
fix(server): cap request bodies and require a session before large uploads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+146
-3
No files matched your search
@@ -0,0 +1,37 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
|
||||
import { auth } from "@sofa/auth/server";
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
/** Ordinary API calls; avatar uploads (≤ 2 MB) fit. */
|
||||
export const DEFAULT_BODY_LIMIT = 4 * MB;
|
||||
/** 100 MB file inputs plus multipart overhead. */
|
||||
export const UPLOAD_BODY_LIMIT = 110 * MB;
|
||||
|
||||
/** Procedures that accept large bodies: file uploads and import payloads. */
|
||||
export const LARGE_BODY_PATHS = new Set([
|
||||
"/rpc/imports/parseFile",
|
||||
"/rpc/imports/parsePayload",
|
||||
"/rpc/imports/createJob",
|
||||
"/rpc/admin/backups/restore",
|
||||
"/api/v1/imports/parse-file",
|
||||
"/api/v1/imports/parse-payload",
|
||||
"/api/v1/imports/jobs",
|
||||
"/api/v1/admin/backups/restore",
|
||||
]);
|
||||
|
||||
const defaultLimit = bodyLimit({ maxSize: DEFAULT_BODY_LIMIT });
|
||||
const uploadLimit = bodyLimit({ maxSize: UPLOAD_BODY_LIMIT });
|
||||
|
||||
/**
|
||||
* Body limits for /rpc and /api/v1. oRPC reads the whole body before procedure
|
||||
* middleware (including auth) runs, so large-body routes check the session first.
|
||||
*/
|
||||
export const apiBodyLimit: MiddlewareHandler = async (c, next) => {
|
||||
if (!LARGE_BODY_PATHS.has(c.req.path)) return defaultLimit(c, next);
|
||||
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
||||
if (!session) return c.json({ error: "Unauthorized" }, 401);
|
||||
return uploadLimit(c, next);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Context, Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { cors } from "hono/cors";
|
||||
|
||||
@@ -12,6 +13,7 @@ import { clearFinishedImportPayloads, recoverStaleImportJobs } from "@sofa/db/qu
|
||||
import { seedPlatforms } from "@sofa/db/seed-platforms";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { apiBodyLimit, UPLOAD_BODY_LIMIT } from "./body-limits";
|
||||
import { getJobSchedules, startJobs, stopJobs } from "./cron";
|
||||
import { handler as rpcHandler } from "./orpc/handler";
|
||||
import { openApiHandler } from "./orpc/openapi-handler";
|
||||
@@ -79,6 +81,12 @@ app.use("*", async (c, next) => {
|
||||
await next();
|
||||
});
|
||||
|
||||
// Request body limits (see body-limits.ts); Better Auth and webhook bodies are small.
|
||||
app.use("/rpc/*", apiBodyLimit);
|
||||
app.use("/api/v1/*", apiBodyLimit);
|
||||
app.use("/api/auth/*", bodyLimit({ maxSize: 1024 * 1024 }));
|
||||
app.use("/api/webhooks/*", bodyLimit({ maxSize: 10 * 1024 * 1024 }));
|
||||
|
||||
// Non-RPC routes
|
||||
app.route("/api/health", healthRoutes);
|
||||
app.route("/api/auth", authRoutes);
|
||||
@@ -166,6 +174,7 @@ const port = Number(process.env.PORT || process.env.API_PORT || 3001);
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
maxRequestBodySize: UPLOAD_BODY_LIMIT,
|
||||
});
|
||||
|
||||
log.info(
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Hono } from "hono";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { UpdateUserPlatformsInput, WatchInput } from "@sofa/api/schemas";
|
||||
import { auth } from "@sofa/auth/server";
|
||||
|
||||
import { apiBodyLimit } from "../src/body-limits";
|
||||
|
||||
vi.mock("@sofa/auth/server", () => ({
|
||||
auth: {
|
||||
api: { getSession: vi.fn<() => void>() },
|
||||
},
|
||||
}));
|
||||
|
||||
const getSession = vi.mocked(auth.api.getSession);
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
const app = new Hono();
|
||||
app.use("/rpc/*", apiBodyLimit);
|
||||
app.post("/rpc/*", async (c) => c.text(String((await c.req.arrayBuffer()).byteLength)));
|
||||
|
||||
function post(path: string, body: NonNullable<RequestInit["body"]>, init: RequestInit = {}) {
|
||||
return app.request(path, { method: "POST", body, ...init });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getSession.mockReset();
|
||||
});
|
||||
|
||||
describe("apiBodyLimit", () => {
|
||||
test("rejects a 5 MB body on an ordinary route with 413", async () => {
|
||||
const res = await post("/rpc/tracking/watch", "x".repeat(5 * MB));
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
|
||||
test("rejects a chunked 5 MB body on an ordinary route with 413", async () => {
|
||||
const chunk = new Uint8Array(MB);
|
||||
let sent = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (sent >= 5) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
sent += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
const res = await post("/rpc/tracking/watch", stream, {
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
|
||||
test("passes a 1 KB body on an ordinary route", async () => {
|
||||
const res = await post("/rpc/tracking/watch", "x".repeat(1024));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("1024");
|
||||
});
|
||||
|
||||
test("rejects an unauthenticated upload before reading the body", async () => {
|
||||
getSession.mockResolvedValue(null);
|
||||
const res = await post("/rpc/imports/parseFile", "x".repeat(1024));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("allows a 5 MB upload with a session", async () => {
|
||||
getSession.mockResolvedValue({
|
||||
session: { id: "s1", userId: "user-1" },
|
||||
user: { id: "user-1", role: "user" },
|
||||
} as never);
|
||||
const res = await post("/rpc/imports/parseFile", "x".repeat(5 * MB));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("array bounds", () => {
|
||||
const ids = (n: number) => Array.from({ length: n }, (_, i) => String(i));
|
||||
|
||||
test("WatchInput caps ids at 20000", () => {
|
||||
expect(WatchInput.safeParse({ scope: "episode", ids: ids(20001) }).success).toBe(false);
|
||||
expect(WatchInput.safeParse({ scope: "episode", ids: ids(20000) }).success).toBe(true);
|
||||
});
|
||||
|
||||
test("UpdateUserPlatformsInput caps platformIds at 500", () => {
|
||||
expect(UpdateUserPlatformsInput.safeParse({ platformIds: Array(501).fill("p") }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(UpdateUserPlatformsInput.safeParse({ platformIds: Array(500).fill("p") }).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1845,6 +1845,7 @@
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 20000,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
@@ -1901,6 +1902,7 @@
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 20000,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
@@ -4862,6 +4864,7 @@
|
||||
"properties": {
|
||||
"platformIds": {
|
||||
"type": "array",
|
||||
"maxItems": 500,
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -72,14 +72,14 @@ export const WatchScope = z
|
||||
export const WatchInput = z
|
||||
.object({
|
||||
scope: WatchScope,
|
||||
ids: z.array(z.string().min(1)).min(1).describe("IDs to mark as watched"),
|
||||
ids: z.array(z.string().min(1)).min(1).max(20000).describe("IDs to mark as watched"),
|
||||
})
|
||||
.meta({ description: "Mark one or more items as watched" });
|
||||
|
||||
export const UnwatchInput = z
|
||||
.object({
|
||||
scope: WatchScope,
|
||||
ids: z.array(z.string().min(1)).min(1).describe("IDs to unwatch"),
|
||||
ids: z.array(z.string().min(1)).min(1).max(20000).describe("IDs to unwatch"),
|
||||
})
|
||||
.meta({ description: "Remove watch records for one or more items" });
|
||||
|
||||
@@ -342,7 +342,7 @@ export const UserPlatformsOutput = z.object({
|
||||
});
|
||||
|
||||
export const UpdateUserPlatformsInput = z.object({
|
||||
platformIds: z.array(z.string()).describe("List of platform IDs the user subscribes to"),
|
||||
platformIds: z.array(z.string()).max(500).describe("List of platform IDs the user subscribes to"),
|
||||
});
|
||||
|
||||
export const CastMemberSchema = z
|
||||
|
||||
Reference in new issue
Block a user