diff --git a/.changeset/safe-graphql-body-limit-configuration.md b/.changeset/safe-graphql-body-limit-configuration.md
new file mode 100644
index 000000000..d019c108f
--- /dev/null
+++ b/.changeset/safe-graphql-body-limit-configuration.md
@@ -0,0 +1,6 @@
+---
+"@croco/problems-core": patch
+"@croco/transports-graphql": patch
+---
+
+Reject unsafe GraphQL request body limits during server initialization with the stable `transports-graphql/body-limit-invalid-configuration` Problem, while preserving an inclusive byte boundary for buffered and streamed requests.
diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json
index 515e4a87c..d94d111e4 100644
--- a/docs/problem-code-registry.json
+++ b/docs/problem-code-registry.json
@@ -1,6 +1,6 @@
{
"version": "croco.problem-code-registry.v1",
- "problemCount": 582,
+ "problemCount": 583,
"problems": [
{
"code": "ACCESS_DENIED",
@@ -15217,6 +15217,36 @@
}
]
},
+ {
+ "code": "transports-graphql/body-limit-invalid-configuration",
+ "category": "InternalServerError",
+ "status": 500,
+ "title": "Internal Server Error",
+ "cookbookPath": "/reference/problem-recovery-cookbook/#transports-graphql-body-limit-invalid-configuration",
+ "recovery": {
+ "cause": "The GraphQL server was configured with an invalid request body byte boundary.",
+ "userAction": "Ask the operator to correct the service configuration before retrying.",
+ "operatorAction": "Set maxBodySizeBytes to a finite positive safe integer and restart the service.",
+ "retryability": "not-retryable",
+ "redactionPolicy": "operator-only",
+ "telemetry": {
+ "eventName": "croco.problem.error",
+ "severity": "error",
+ "attributes": ["problem.code", "problem.category", "problem.status"]
+ }
+ },
+ "lifecycle": {
+ "status": "active"
+ },
+ "sources": [
+ {
+ "file": "packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts",
+ "line": 8,
+ "column": 5,
+ "kind": "problem-constructor"
+ }
+ ]
+ },
{
"code": "transports-graphql/request-body-aborted",
"category": "BadRequest",
@@ -15241,7 +15271,7 @@
"sources": [
{
"file": "packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts",
- "line": 47,
+ "line": 63,
"column": 3,
"kind": "problem-class"
}
@@ -15271,7 +15301,7 @@
"sources": [
{
"file": "packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts",
- "line": 33,
+ "line": 49,
"column": 5,
"kind": "problem-constructor"
}
@@ -15301,7 +15331,7 @@
"sources": [
{
"file": "packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts",
- "line": 4,
+ "line": 20,
"column": 3,
"kind": "problem-class"
}
@@ -15331,7 +15361,7 @@
"sources": [
{
"file": "packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts",
- "line": 16,
+ "line": 32,
"column": 3,
"kind": "problem-class"
}
@@ -15361,7 +15391,7 @@
"sources": [
{
"file": "packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts",
- "line": 24,
+ "line": 40,
"column": 3,
"kind": "problem-class"
}
diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md
index ec1baa190..0700635b7 100644
--- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md
+++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md
@@ -7,7 +7,7 @@ description: Generated Croco Problem code registry with recovery and telemetry m
> Generated by `pnpm problem-registry:write`. Do not edit this file by hand.
-This cookbook documents 582 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`, and generated client union types are emitted at `packages/problems-core/src/generated/problem-code-registry.ts`.
+This cookbook documents 583 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`, and generated client union types are emitted at `packages/problems-core/src/generated/problem-code-registry.ts`.
## Index
@@ -520,6 +520,7 @@ This cookbook documents 582 public Croco Problem codes. The deterministic JSON r
| [`testing/transaction-context-not-active`](#testing-transaction-context-not-active) | InternalServerError | 500 | conditional | operator-only | active | 1 |
| [`TOKEN_LIMIT_EXCEEDED`](#token-limit-exceeded) | BadRequest | 400 | not-retryable | public | active | 1 |
| [`TOOL_EXECUTION_ERROR`](#tool-execution-error) | InternalServerError | 500 | conditional | operator-only | active | 1 |
+| [`transports-graphql/body-limit-invalid-configuration`](#transports-graphql-body-limit-invalid-configuration) | InternalServerError | 500 | not-retryable | operator-only | active | 1 |
| [`transports-graphql/request-body-aborted`](#transports-graphql-request-body-aborted) | BadRequest | 400 | not-retryable | public | active | 1 |
| [`transports-graphql/request-body-too-large`](#transports-graphql-request-body-too-large) | PayloadTooLarge | 413 | not-retryable | public | active | 1 |
| [`transports-graphql/resolvers-not-configured`](#transports-graphql-resolvers-not-configured) | InternalServerError | 500 | conditional | operator-only | active | 1 |
@@ -9730,6 +9731,24 @@ Sources:
- `packages/llm-core/src/libs/problems/LlmServiceProblem.ts:61:5` (problem-constructor)
+
+
+## `transports-graphql/body-limit-invalid-configuration`
+
+- Category: `InternalServerError`
+- HTTP status: `500` Internal Server Error
+- Retryability: `not-retryable`
+- Redaction policy: `operator-only`
+- Lifecycle: `active`
+- Cause: The GraphQL server was configured with an invalid request body byte boundary.
+- User action: Ask the operator to correct the service configuration before retrying.
+- Operator action: Set maxBodySizeBytes to a finite positive safe integer and restart the service.
+- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status`
+
+Sources:
+
+- `packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts:8:5` (problem-constructor)
+
## `transports-graphql/request-body-aborted`
@@ -9746,7 +9765,7 @@ Sources:
Sources:
-- `packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts:47:3` (problem-class)
+- `packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts:63:3` (problem-class)
@@ -9764,7 +9783,7 @@ Sources:
Sources:
-- `packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts:33:5` (problem-constructor)
+- `packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts:49:5` (problem-constructor)
@@ -9782,7 +9801,7 @@ Sources:
Sources:
-- `packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts:4:3` (problem-class)
+- `packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts:20:3` (problem-class)
@@ -9800,7 +9819,7 @@ Sources:
Sources:
-- `packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts:16:3` (problem-class)
+- `packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts:32:3` (problem-class)
@@ -9818,7 +9837,7 @@ Sources:
Sources:
-- `packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts:24:3` (problem-class)
+- `packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts:40:3` (problem-class)
diff --git a/packages/problems-core/src/generated/problem-code-registry.ts b/packages/problems-core/src/generated/problem-code-registry.ts
index 2256fd6d2..d9eca4509 100644
--- a/packages/problems-core/src/generated/problem-code-registry.ts
+++ b/packages/problems-core/src/generated/problem-code-registry.ts
@@ -3,7 +3,7 @@ import type { ProblemCodeRegistry } from "../libs/ProblemRegistry";
export const CROCO_PROBLEM_CODE_REGISTRY = {
version: "croco.problem-code-registry.v1",
- problemCount: 582,
+ problemCount: 583,
problems: [
{
code: "ACCESS_DENIED",
@@ -15873,6 +15873,38 @@ export const CROCO_PROBLEM_CODE_REGISTRY = {
},
],
},
+ {
+ code: "transports-graphql/body-limit-invalid-configuration",
+ category: "InternalServerError",
+ status: 500,
+ title: "Internal Server Error",
+ cookbookPath:
+ "/reference/problem-recovery-cookbook/#transports-graphql-body-limit-invalid-configuration",
+ recovery: {
+ cause: "The GraphQL server was configured with an invalid request body byte boundary.",
+ userAction: "Ask the operator to correct the service configuration before retrying.",
+ operatorAction:
+ "Set maxBodySizeBytes to a finite positive safe integer and restart the service.",
+ retryability: "not-retryable",
+ redactionPolicy: "operator-only",
+ telemetry: {
+ eventName: "croco.problem.error",
+ severity: "error",
+ attributes: ["problem.code", "problem.category", "problem.status"],
+ },
+ },
+ lifecycle: {
+ status: "active",
+ },
+ sources: [
+ {
+ file: "packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts",
+ line: 8,
+ column: 5,
+ kind: "problem-constructor",
+ },
+ ],
+ },
{
code: "transports-graphql/request-body-aborted",
category: "BadRequest",
@@ -15898,7 +15930,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = {
sources: [
{
file: "packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts",
- line: 47,
+ line: 63,
column: 3,
kind: "problem-class",
},
@@ -15930,7 +15962,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = {
sources: [
{
file: "packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts",
- line: 33,
+ line: 49,
column: 5,
kind: "problem-constructor",
},
@@ -15963,7 +15995,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = {
sources: [
{
file: "packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts",
- line: 4,
+ line: 20,
column: 3,
kind: "problem-class",
},
@@ -15996,7 +16028,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = {
sources: [
{
file: "packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts",
- line: 16,
+ line: 32,
column: 3,
kind: "problem-class",
},
@@ -16029,7 +16061,7 @@ export const CROCO_PROBLEM_CODE_REGISTRY = {
sources: [
{
file: "packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts",
- line: 24,
+ line: 40,
column: 3,
kind: "problem-class",
},
diff --git a/packages/transports-graphql/README.md b/packages/transports-graphql/README.md
index 4725d4ca2..ecad57506 100644
--- a/packages/transports-graphql/README.md
+++ b/packages/transports-graphql/README.md
@@ -23,6 +23,12 @@ const schema = await new SchemaCompiler().compile({ resolvers: [HealthResolver]
const server = new GraphQLServer({ schema });
```
+`maxBodySizeBytes` defaults to 1 MiB and must be a finite positive safe integer. The
+server validates this option during initialization, before opening its listener. Both
+declared `Content-Length` values and actual streamed bytes use an inclusive boundary:
+a request with exactly the configured number of bytes is accepted, while the next byte
+is rejected with `transports-graphql/request-body-too-large`.
+
Schemas compiled through `SchemaCompiler` execute the `UseGuards`, `Roles`, and
`UseInterceptors` declarations recorded by `@croco/protocols-graphql`. Request
headers are available as `context.headers`, custom server context is preserved, and
diff --git a/packages/transports-graphql/src/index.ts b/packages/transports-graphql/src/index.ts
index 6ab05e5c7..2a08d5e24 100644
--- a/packages/transports-graphql/src/index.ts
+++ b/packages/transports-graphql/src/index.ts
@@ -6,6 +6,7 @@
/** GraphQL Yoga server runtime. */
export { GraphQLServer } from "./libs/GraphQLServer";
export {
+ GraphQLBodyLimitConfigurationProblem,
GraphQLRequestBodyAbortedProblem,
GraphQLRequestBodyTooLargeProblem,
GraphQLResolversNotConfiguredProblem,
diff --git a/packages/transports-graphql/src/libs/GraphQLServer.ts b/packages/transports-graphql/src/libs/GraphQLServer.ts
index 1aea72aca..0d7bb0edd 100644
--- a/packages/transports-graphql/src/libs/GraphQLServer.ts
+++ b/packages/transports-graphql/src/libs/GraphQLServer.ts
@@ -6,6 +6,7 @@ import { Problem } from "@croco/problems-core";
import { isProblem, problemToGraphQLError } from "@croco/protocols-graphql";
import { createYoga, maskError } from "graphql-yoga";
import {
+ GraphQLBodyLimitConfigurationProblem,
GraphQLRequestBodyAbortedProblem,
GraphQLRequestBodyTooLargeProblem,
GraphQLSchemaNotConfiguredProblem,
@@ -38,12 +39,22 @@ export class GraphQLServer {
private yogaHandler: YogaHandler | null = null;
private server: Server | null = null;
private initialized = false;
+ private maxBodySizeBytes = DEFAULT_MAX_BODY_SIZE_BYTES;
constructor(private options: GraphQLServerOptions = {}) {}
async initialize(): Promise {
if (this.initialized) return;
+ const maxBodySizeBytes =
+ this.options.maxBodySizeBytes === undefined
+ ? DEFAULT_MAX_BODY_SIZE_BYTES
+ : this.options.maxBodySizeBytes;
+ if (!Number.isSafeInteger(maxBodySizeBytes) || maxBodySizeBytes <= 0) {
+ throw new GraphQLBodyLimitConfigurationProblem();
+ }
+ this.maxBodySizeBytes = maxBodySizeBytes;
+
const {
schema,
schemaOptions,
@@ -181,7 +192,7 @@ export class GraphQLServer {
private getBody(req: IncomingMessage): Promise {
return new Promise((resolve, reject) => {
- const maxBodySizeBytes = this.options.maxBodySizeBytes ?? DEFAULT_MAX_BODY_SIZE_BYTES;
+ const maxBodySizeBytes = this.maxBodySizeBytes;
const contentLength = req.headers["content-length"];
if (typeof contentLength === "string") {
diff --git a/packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts b/packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts
index 4b45c7ace..0fcbbf9d2 100644
--- a/packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts
+++ b/packages/transports-graphql/src/libs/problems/GraphQLTransportProblems.ts
@@ -1,5 +1,21 @@
import { Problem, ProblemCategory } from "@croco/problems-core";
+const PROBLEM_TYPE_BASE = "https://croco.dev/problems/transports-graphql";
+
+/** The configured request body boundary cannot be enforced safely. */
+export class GraphQLBodyLimitConfigurationProblem extends Problem {
+ constructor() {
+ super(
+ "transports-graphql/body-limit-invalid-configuration",
+ ProblemCategory.InternalServerError,
+ "maxBodySizeBytes must be a finite positive safe integer",
+ {
+ type: `${PROBLEM_TYPE_BASE}/body-limit-invalid-configuration`,
+ },
+ );
+ }
+}
+
export class GraphQLResolversNotConfiguredProblem extends Problem {
readonly code = "transports-graphql/resolvers-not-configured";
readonly category = ProblemCategory.InternalServerError;
diff --git a/packages/transports-graphql/src/tests/GraphQLServer.spec.ts b/packages/transports-graphql/src/tests/GraphQLServer.spec.ts
index 933e4c90b..10f867ccf 100644
--- a/packages/transports-graphql/src/tests/GraphQLServer.spec.ts
+++ b/packages/transports-graphql/src/tests/GraphQLServer.spec.ts
@@ -1,4 +1,5 @@
import "reflect-metadata";
+import { request as httpRequest } from "node:http";
import { Container } from "@croco/framework-context";
import { Problem, ProblemCategory } from "@croco/problems-core";
import {
@@ -22,6 +23,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
import { GraphQLServer } from "../libs/GraphQLServer";
import { SchemaCompiler } from "../libs/SchemaCompiler";
import {
+ GraphQLBodyLimitConfigurationProblem,
GraphQLResolversNotConfiguredProblem,
GraphQLSchemaNotConfiguredProblem,
GraphQLServerNotInitializedProblem,
@@ -433,6 +435,77 @@ describe("GraphQLServer integration", () => {
);
});
+ it.each([
+ Number.NaN,
+ Number.POSITIVE_INFINITY,
+ Number.NEGATIVE_INFINITY,
+ -1,
+ 0,
+ 0.5,
+ 2 ** 53,
+ null as unknown as number,
+ ])(
+ "should reject unsafe maxBodySizeBytes configuration %s during initialization",
+ async (maxBodySizeBytes) => {
+ const testServer = new GraphQLServer({
+ schemaOptions: {
+ resolvers: [UserResolver],
+ autoDiscover: false,
+ },
+ maxBodySizeBytes,
+ });
+
+ await expect(testServer.initialize()).rejects.toMatchObject({
+ code: "transports-graphql/body-limit-invalid-configuration",
+ category: "InternalServerError",
+ detail: "maxBodySizeBytes must be a finite positive safe integer",
+ });
+ await expect(testServer.initialize()).rejects.toBeInstanceOf(
+ GraphQLBodyLimitConfigurationProblem,
+ );
+ },
+ );
+
+ it("should enforce the exact byte boundary for buffered and streamed bodies", async () => {
+ const body = JSON.stringify({ query: "{ hello } # 😀" });
+ const limit = Buffer.byteLength(body);
+ const testServer = new GraphQLServer({
+ schemaOptions: {
+ resolvers: [UserResolver],
+ autoDiscover: false,
+ },
+ maxBodySizeBytes: limit,
+ });
+
+ await testServer.start(4003);
+
+ try {
+ const bufferedAtLimit = await fetch("http://localhost:4003/graphql", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body,
+ });
+ const bufferedOverLimit = await fetch("http://localhost:4003/graphql", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: `${body} `,
+ });
+ const multibyteOffset = body.indexOf("😀");
+ const streamedAtLimit = await sendChunkedRequest(4003, [
+ body.slice(0, multibyteOffset),
+ body.slice(multibyteOffset),
+ ]);
+ const streamedOverLimit = await sendChunkedRequest(4003, [body, " "]);
+
+ expect(bufferedAtLimit.status).toBe(200);
+ expect(bufferedOverLimit.status).toBe(413);
+ expect(streamedAtLimit.status).toBe(200);
+ expect(streamedOverLimit.status).toBe(413);
+ } finally {
+ await testServer.stop();
+ }
+ });
+
it("should reject oversized request bodies with 413", async () => {
const testServer = new GraphQLServer({
schemaOptions: {
@@ -866,3 +939,36 @@ async function executeQuery(
data: await response.json(),
};
}
+
+function sendChunkedRequest(
+ port: number,
+ chunks: readonly string[],
+): Promise<{ readonly status: number; readonly body: string }> {
+ return new Promise((resolve, reject) => {
+ const request = httpRequest(
+ {
+ host: "localhost",
+ port,
+ path: "/graphql",
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ },
+ (response) => {
+ const responseChunks: Buffer[] = [];
+ response.on("data", (chunk: Buffer) => responseChunks.push(chunk));
+ response.on("end", () => {
+ resolve({
+ status: response.statusCode ?? 0,
+ body: Buffer.concat(responseChunks).toString(),
+ });
+ });
+ },
+ );
+
+ request.on("error", reject);
+ for (const chunk of chunks) {
+ request.write(chunk);
+ }
+ request.end();
+ });
+}
diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json
index dc8dfa3cf..fec0619e8 100644
--- a/public-api-surface.snapshot.json
+++ b/public-api-surface.snapshot.json
@@ -27135,6 +27135,12 @@
],
"sourceEntrypoint": "packages/transports-graphql/src/index.ts",
"runtimeExports": [
+ {
+ "name": "GraphQLBodyLimitConfigurationProblem",
+ "exportKind": "named",
+ "source": "./libs/problems/GraphQLTransportProblems",
+ "declarationKind": "class"
+ },
{
"name": "GraphQLRequestBodyAbortedProblem",
"exportKind": "named",
diff --git a/scripts/problem-registry.mts b/scripts/problem-registry.mts
index 1a68bb1d4..f59a451b5 100644
--- a/scripts/problem-registry.mts
+++ b/scripts/problem-registry.mts
@@ -1997,6 +1997,15 @@ const recoveryMetadataByCode = {
redactionPolicy: "operator-only",
severity: "error",
}),
+ "transports-graphql/body-limit-invalid-configuration": recovery({
+ cause: "The GraphQL server was configured with an invalid request body byte boundary.",
+ userAction: "Ask the operator to correct the service configuration before retrying.",
+ operatorAction:
+ "Set maxBodySizeBytes to a finite positive safe integer and restart the service.",
+ retryability: "not-retryable",
+ redactionPolicy: "operator-only",
+ severity: "error",
+ }),
CROCO_HTTP_SECURITY_001: recovery({
cause:
"HTTP bootstrap validation found a generated or application app without the required security middleware set.",
diff --git a/scripts/static-misuse-empty-catch-allowlist.json b/scripts/static-misuse-empty-catch-allowlist.json
index 839f7917f..3b18d0f90 100644
--- a/scripts/static-misuse-empty-catch-allowlist.json
+++ b/scripts/static-misuse-empty-catch-allowlist.json
@@ -51,7 +51,7 @@
{
"package": "@croco/transports-graphql",
"file": "packages/transports-graphql/src/libs/GraphQLServer.ts",
- "line": 173,
+ "line": 184,
"excerpt": "} catch {",
"reason": "Startup logging is optional and must not prevent the GraphQL server from resolving a successful listen callback.",
"owner": "framework-error-handling",