diff --git a/.env.example b/.env.example index 0d8fad5f..0fdfc64d 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,7 @@ CONTEXTFORGE_AUTH_HEADER_NAME=Authorization # handlers wait for email delivery before responding. PASSWORD_RESET_REQUEST_TIMEOUT_MS=30000 -# Max request body for config import (POST /api/v1/import, /api/v1/admin/import/preview). +# Max request body for config import (POST /api/v1/import). # Other /api/* routes keep Fastify's 1 MiB default. IMPORT_BODY_LIMIT_BYTES=10485760 diff --git a/.env.prod.example b/.env.prod.example index 8d308a9e..7746fa21 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -22,7 +22,7 @@ CONTEXTFORGE_AUTH_HEADER_NAME=Authorization # handlers wait for email delivery before responding. PASSWORD_RESET_REQUEST_TIMEOUT_MS=30000 -# Max request body for config import (POST /api/v1/import, /api/v1/admin/import/preview). +# Max request body for config import (POST /api/v1/import). # Other /api/* routes keep Fastify's 1 MiB default. IMPORT_BODY_LIMIT_BYTES=10485760 diff --git a/server/src/config.ts b/server/src/config.ts index eae04093..9745dadd 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -66,7 +66,7 @@ export const config = { // headroom than a plain API call needs. oauthProxyTimeoutMs: Number(optional("OAUTH_PROXY_TIMEOUT_MS", "30000")), - // Body limit for the config-import proxy routes only (routes/proxy/catch-all.ts); + // Body limit for the config-import proxy route only (routes/proxy/catch-all.ts); // full exports outgrow Fastify's 1 MiB default, every other /api/* route keeps it. importBodyLimitBytes: Number(optional("IMPORT_BODY_LIMIT_BYTES", "10485760")), diff --git a/server/src/routes/proxy/catch-all.ts b/server/src/routes/proxy/catch-all.ts index d1bbc865..f28e554c 100644 --- a/server/src/routes/proxy/catch-all.ts +++ b/server/src/routes/proxy/catch-all.ts @@ -32,8 +32,8 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]); // Verified against mcpgateway: /api/logs is the only such router. const UPSTREAM_API_PREFIXES = ["logs/"]; -// POST-only upstream routes that take a whole config export as the body. -const IMPORT_PATHS = ["v1/import", "v1/admin/import/preview"]; +// Upstream route that takes a whole config export as the body. +const IMPORT_PATH = "v1/import"; /** Browser `/api/` -> the path FastAPI actually serves. */ function toUpstreamPath(wildcard: string): string { @@ -188,14 +188,12 @@ export default async function catchAllProxyRoute(fastify: FastifyInstance): Prom }; // Config import bodies (full exports) outgrow the 1 MiB default; raise the - // limit on these routes only. Static paths win over the wildcard below. - for (const path of IMPORT_PATHS) { - fastify.post( - `/api/${path}`, - { bodyLimit: config.importBodyLimitBytes, preHandler: [fastify.sessionAuth, csrfIfUnsafe] }, - (request, reply) => proxyToUpstream(request, reply, path), - ); - } + // limit on this route only. Static paths win over the wildcard below. + fastify.post( + `/api/${IMPORT_PATH}`, + { bodyLimit: config.importBodyLimitBytes, preHandler: [fastify.sessionAuth, csrfIfUnsafe] }, + (request, reply) => proxyToUpstream(request, reply, IMPORT_PATH), + ); fastify.all("/api/*", { preHandler: [fastify.sessionAuth, csrfIfUnsafe] }, (request, reply) => proxyToUpstream(request, reply, (request.params as Record)["*"] ?? ""), diff --git a/server/test/proxy.test.ts b/server/test/proxy.test.ts index 0dfb9627..16f12de5 100644 --- a/server/test/proxy.test.ts +++ b/server/test/proxy.test.ts @@ -289,29 +289,26 @@ describe("ALL /api/*", () => { }); }); -describe("POST /api/v1/import, /api/v1/admin/import/preview", () => { +describe("POST /api/v1/import", () => { // JSON payload of roughly `bytes` size. const payloadOf = (bytes: number) => ({ import_data: { blob: "x".repeat(bytes) } }); - it.each([["/api/v1/import"], ["/api/v1/admin/import/preview"]])( - "forwards %s with a body over the 1 MiB default", - async (url) => { - const app = await buildApp(); - const { cookie, csrfToken } = await seedSession(app); - - const response = await app.fastify.inject({ - method: "POST", - url, - headers: { cookie, "x-csrf-token": csrfToken }, - payload: payloadOf(1.5 * MIB), - }); - - expect(response.statusCode).toBe(200); - expect(lastRequest?.path).toBe(url.slice("/api".length)); - expect(lastRequest?.authorization).toBe("Bearer test-bearer-token"); - expect(JSON.parse(lastRequest!.body).import_data.blob).toHaveLength(1.5 * MIB); - }, - ); + it("forwards a body over the 1 MiB default", async () => { + const app = await buildApp(); + const { cookie, csrfToken } = await seedSession(app); + + const response = await app.fastify.inject({ + method: "POST", + url: "/api/v1/import", + headers: { cookie, "x-csrf-token": csrfToken }, + payload: payloadOf(1.5 * MIB), + }); + + expect(response.statusCode).toBe(200); + expect(lastRequest?.path).toBe("/v1/import"); + expect(lastRequest?.authorization).toBe("Bearer test-bearer-token"); + expect(JSON.parse(lastRequest!.body).import_data.blob).toHaveLength(1.5 * MIB); + }); it("keeps the 1 MiB default on every other /api/* route", async () => { const app = await buildApp();