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
23 changes: 22 additions & 1 deletion src/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 22 additions & 10 deletions src/fastify.js
Original file line number Diff line number Diff line change
@@ -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'
19 changes: 17 additions & 2 deletions src/koa.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
32 changes: 30 additions & 2 deletions src/lambda.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
18 changes: 14 additions & 4 deletions src/tinyhttp.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
77 changes: 77 additions & 0 deletions src/validation.js
Original file line number Diff line number Diff line change
@@ -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])
}
}
50 changes: 50 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
37 changes: 37 additions & 0 deletions test/lambda.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
})
})
Loading