diff --git a/src/base.js b/src/base.js index 51246c6..0ff5894 100644 --- a/src/base.js +++ b/src/base.js @@ -9,6 +9,8 @@ import { GraphQLError, } from 'graphql' +import { with_timeout } from './validation.js' + const no_schema_error = () => { throw new Error("Option 'schema' is required") } @@ -56,6 +58,7 @@ export default implementation => root_value, build_context = () => ({}), format_error = error => error, + context_timeout = 5000, } = {}) => async (...input) => { const { query, variable_values, operation_name, reply } = implementation( @@ -92,7 +95,25 @@ export default implementation => return } - const context_value = (await build_context(...input)) ?? {} + // Build context with timeout protection + let context_value + try { + const wrapped_build_context = with_timeout(build_context, context_timeout) + context_value = (await wrapped_build_context(...input)) ?? {} + } catch (error) { + reply({ + errors: [ + format_error( + new GraphQLError( + error.message.includes('timed out') + ? 'Context building timed out' + : error.message, + ), + ), + ], + }) + return + } const options = { document, schema, diff --git a/src/fastify.js b/src/fastify.js index 40387bc..daab086 100644 --- a/src/fastify.js +++ b/src/fastify.js @@ -1,15 +1,27 @@ import base from './base.js' +import { validate_request_body } from './validation.js' +import { GraphQLError } from 'graphql' -const Fastify = ( - { body: { query, variables, operationName, operation_name } }, - reply, -) => ({ - query, - variable_values: variables, - operation_name: operation_name ?? operationName, - reply: ({ type = 'application/json', ...body }) => - reply.status(200).type(type).send(body), -}) +const Fastify = ({ body }, reply) => { + // Validate request body + try { + validate_request_body(body) + } catch (error) { + const graphql_error = + error instanceof GraphQLError ? error : new GraphQLError(error.message) + reply.status(200).type('application/json').send({ errors: [graphql_error] }) + return { query: null, variable_values: null, operation_name: null, reply: () => {} } + } + + const { query, variables, operationName, operation_name } = body + return { + query, + variable_values: variables, + operation_name: operation_name ?? operationName, + reply: ({ type = 'application/json', ...body }) => + reply.status(200).type(type).send(body), + } +} export default base(Fastify) export { k_field } from './base.js' diff --git a/src/koa.js b/src/koa.js index 6c5222d..fbfb1a3 100644 --- a/src/koa.js +++ b/src/koa.js @@ -1,8 +1,23 @@ import base from './base.js' +import { validate_request_body } from './validation.js' +import { GraphQLError } from 'graphql' const Koa = context => { - const { query, variables, operationName, operation_name } = - context.request.body + const request_body = context.request.body + + // Validate request body + try { + validate_request_body(request_body) + } catch (error) { + const graphql_error = + error instanceof GraphQLError ? error : new GraphQLError(error.message) + context.status = 200 + context.type = 'application/json' + context.body = { errors: [graphql_error] } + return { query: null, variable_values: null, operation_name: null, reply: () => {} } + } + + const { query, variables, operationName, operation_name } = request_body return { query, variable_values: variables, diff --git a/src/lambda.js b/src/lambda.js index cba19ca..92d14b1 100644 --- a/src/lambda.js +++ b/src/lambda.js @@ -1,8 +1,36 @@ import base from './base.js' +import { validate_request_body } from './validation.js' +import { GraphQLError } from 'graphql' const Lambda = ({ body: raw_body }, context, reply) => { - const { query, operationName, operation_name, variables } = - JSON.parse(raw_body) + // Parse JSON with error handling + let parsed + try { + parsed = JSON.parse(raw_body) + } catch (error) { + reply(null, { + statusCode: 400, + body: JSON.stringify({ + errors: [{ message: 'Invalid JSON in request body' }], + }), + }) + return { query: null, variable_values: null, operation_name: null, reply } + } + + // Validate request body structure + try { + validate_request_body(parsed) + } catch (error) { + reply(null, { + statusCode: 200, + body: JSON.stringify({ + errors: [error instanceof GraphQLError ? error : { message: error.message }], + }), + }) + return { query: null, variable_values: null, operation_name: null, reply } + } + + const { query, operationName, operation_name, variables } = parsed return { query, variable_values: variables, diff --git a/src/tinyhttp.js b/src/tinyhttp.js index 77236f5..1188d64 100644 --- a/src/tinyhttp.js +++ b/src/tinyhttp.js @@ -1,9 +1,19 @@ import base from './base.js' +import { validate_request_body } from './validation.js' +import { GraphQLError } from 'graphql' -const TinyHttp = ( - { body: { query, variables, operationName, operation_name } = {} }, - response, -) => { +const TinyHttp = ({ body = {} }, response) => { + // Validate request body + try { + validate_request_body(body) + } catch (error) { + const graphql_error = + error instanceof GraphQLError ? error : new GraphQLError(error.message) + response.status(200).json({ errors: [graphql_error] }) + return { query: null, variable_values: null, operation_name: null, reply: () => {} } + } + + const { query, variables, operationName, operation_name } = body return { query, variable_values: variables, diff --git a/src/validation.js b/src/validation.js new file mode 100644 index 0000000..7de8111 --- /dev/null +++ b/src/validation.js @@ -0,0 +1,77 @@ +import { GraphQLError } from 'graphql' + +/** + * Maximum allowed query size in bytes (100KB) + */ +const MAX_QUERY_SIZE = 102400 + +/** + * Validates request body structure and content + * @param {unknown} body - Request body to validate + * @returns {void} + * @throws {GraphQLError} If validation fails + */ +export function validate_request_body(body) { + // Check if body is an object (not array, null, or primitive) + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new GraphQLError('Request body must be an object') + } + + // Check if query is provided and is a string + if (body.query !== undefined && typeof body.query !== 'string') { + throw new GraphQLError('Query must be a string') + } + + // Check query size limit + if (body.query && body.query.length > MAX_QUERY_SIZE) { + throw new GraphQLError( + `Query too large (${body.query.length} bytes, max ${MAX_QUERY_SIZE} bytes)`, + ) + } + + // Check variables is object if provided + if ( + body.variables !== undefined && + body.variables !== null && + typeof body.variables !== 'object' + ) { + throw new GraphQLError('Variables must be an object') + } + + // Check operationName is string if provided + if ( + body.operationName !== undefined && + body.operationName !== null && + typeof body.operationName !== 'string' + ) { + throw new GraphQLError('Operation name must be a string') + } + + // Check operation_name is string if provided (snake_case variant) + if ( + body.operation_name !== undefined && + body.operation_name !== null && + typeof body.operation_name !== 'string' + ) { + throw new GraphQLError('Operation name must be a string') + } +} + +/** + * Wraps build_context with timeout protection + * @param {Function} build_context - Context builder function + * @param {number} timeout_ms - Timeout in milliseconds (default 5000) + * @returns {Function} Wrapped context builder + */ +export function with_timeout(build_context, timeout_ms = 5000) { + return async (...args) => { + const timeout_promise = new Promise((_, reject) => + setTimeout( + () => reject(new Error('Context building timed out')), + timeout_ms, + ), + ) + + return Promise.race([build_context(...args), timeout_promise]) + } +} diff --git a/test/index.test.js b/test/index.test.js index c0d2c2e..bce20c0 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -145,5 +145,55 @@ test('koa adapter', async t => { } }) + await t.test('should return error for query exceeding size limit', async () => { + const huge_query = `{ ${'me { name } '.repeat(20000)} }` + const { errors } = await request({ query: huge_query }) + assert.ok(errors) + assert.ok(errors[0].message.toLowerCase().includes('too large')) + }) + + await t.test('should timeout slow build_context', async () => { + const Koa = (await import('koa')).default + const bodyParser = (await import('koa-bodyparser')).default + const graphql_http = (await import('../src/koa.js')).default + + const slow_server = await new Promise(resolve => { + const app = new Koa() + .use(bodyParser()) + .use( + graphql_http({ + ...options, + build_context: async () => { + await setTimeout(10000) + return {} + }, + }), + ) + .listen(3001, () => { + resolve(app) + }) + }) + + const response = await fetch('http://localhost:3001', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ me { name } }', operation_name: null }), + }) + const body = await response.json() + console.log('Response body:', JSON.stringify(body, null, 2)) + + assert.ok(body.errors, 'Expected errors in response') + assert.ok( + body.errors[0].message.toLowerCase().includes('timeout') || + body.errors[0].message.toLowerCase().includes('context'), + ) + + await new Promise(resolve => slow_server.close(resolve)) + }) + + // TODO: Add SSE stream error cleanup test + // Skipping for now due to stream reading complexity with fetch API + // The error handling code exists in base.js stream_response() catch block + return new Promise(resolve => server.close(resolve)) }) diff --git a/test/lambda.test.js b/test/lambda.test.js index cc1135a..59b01ad 100644 --- a/test/lambda.test.js +++ b/test/lambda.test.js @@ -133,4 +133,41 @@ test('lambda adapter', async t => { const body = JSON.parse(result.body) assert.deepStrictEqual(body.data.me, { name: 'Alice' }) }) + + await t.test('should return 400 for malformed JSON', async () => { + const event = { + body: '{invalid json}', + } + const result = await invoke_lambda(event) + + assert.strictEqual(result.statusCode, 400) + const body = JSON.parse(result.body) + assert.ok(body.errors) + assert.ok(body.errors[0].message.toLowerCase().includes('json')) + }) + + await t.test('should return error for query exceeding size limit', async () => { + const huge_query = `{ ${'me { name } '.repeat(20000)} }` + const event = create_lambda_event({ + query: huge_query, + }) + const result = await invoke_lambda(event) + + assert.strictEqual(result.statusCode, 200) + const body = JSON.parse(result.body) + assert.ok(body.errors) + assert.ok(body.errors[0].message.toLowerCase().includes('too large')) + }) + + await t.test('should return error for non-object request body', async () => { + const event = { + body: JSON.stringify('not an object'), + } + const result = await invoke_lambda(event) + + assert.strictEqual(result.statusCode, 200) + const body = JSON.parse(result.body) + assert.ok(body.errors) + assert.ok(body.errors[0].message.toLowerCase().includes('object')) + }) })