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",