Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")),

Expand Down
18 changes: 8 additions & 10 deletions server/src/routes/proxy/catch-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<wildcard>` -> the path FastAPI actually serves. */
function toUpstreamPath(wildcard: string): string {
Expand Down Expand Up @@ -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<string, string>)["*"] ?? ""),
Expand Down
37 changes: 17 additions & 20 deletions server/test/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading