Mercurius: Incorrect Content-Type parsing can lead to CSRF attack
Description
Mercurius is a GraphQL adapter for Fastify. Prior to version 16.4.0, a cross-site request forgery (CSRF) vulnerability was identified. The issue arises from incorrect parsing of the Content-Type header in requests. Specifically, requests with Content-Type values such as application/x-www-form-urlencoded, multipart/form-data, or text/plain could be misinterpreted as application/json. This misinterpretation bypasses the preflight checks performed by the fetch() API, potentially allowing unauthorized actions to be performed on behalf of an authenticated user. This issue has been patched in version 16.4.0.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A CSRF vulnerability in Mercurius <16.4.0 allows attackers to bypass CORS preflight checks by misinterpreting certain Content-Type headers as application/json.
Vulnerability
Overview
Mercurius is a GraphQL adapter for Fastify. Prior to version 16.4.0, a Cross-Site Request Forgery (CSRF) vulnerability existed due to incorrect Content-Type header parsing [1][2]. Specifically, requests with Content-Type values such as application/x-www-form-urlencoded, multipart/form-data, or text/plain were misinterpreted as application/json [2][4]. This allowed requests that would normally be classified as "simple" under the CORS specification (and thus not trigger a preflight OPTIONS request) to be processed as GraphQL requests [1].
Exploitation
An attacker can craft a malicious website that sends a POST request to a victim's Mercurius endpoint with a Content-Type header that the server misinterprets, while the request body contains a JSON GraphQL query or mutation in the body [4]. Because the browser considers the request a "simple" request (no custom headers, misparsed Content-Type), browsers do not send a preflight OPTIONS request. If the victim is authenticated (e.g., via cookies), the malicious site can perform unauthorized actions on their behalf without consent [1][2].
Impact
Successful exploitation allows an attacker to execute arbitrary GraphQL operations — including mutations — as the authenticated user [1]. This could lead to data theft, unauthorized modifications, or other actions depending on the application's GraphQL schema [4]. No special privileges beyond standard user authentication are required; the attack is purely CSRF-driven [2].
Mitigation
The vulnerability is patched in Mercurius version 16.4.0 [2][4]. The fix implements CSRF prevention that ensures GraphQL requests are never considered "simple" under the CORS specification, either by requiring a non-permissive Content-Type (such as application/json) or by checking for custom headers like X-Mercurius-Operation-Name that trigger a preflight request [1]. Users are advised to upgrade immediately. No known workarounds are documented.
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
mercuriusnpm | < 16.4.0 | 16.4.0 |
Affected products
2- mercurius-js/mercuriusv5Range: < 16.4.0
Patches
1962d402ec7a9feat: csrf prevention (#1187)
9 files changed · +1285 −2
docsify/sidebar.md+2 −0 modified@@ -21,6 +21,8 @@ - [TypeScript Usage](/docs/typescript) - [HTTP](/docs/http) - [GraphQL over WebSocket](/docs/graphql-over-websocket.md) +- [Security](/docs/security) + - [CSRF Prevention](/docs/security/csrf-prevention.md) - [Integrations](/docs/integrations/) - [nexus](/docs/integrations/nexus) - [TypeGraphQL](/docs/integrations/type-graphql)
docs/security/csrf-prevention.md+391 −0 added@@ -0,0 +1,391 @@ +# CSRF Prevention + +Mercurius includes built-in Cross-Site Request Forgery (CSRF) prevention to protect your GraphQL endpoints from malicious requests. + +## What is CSRF? + +[Cross-Site Request Forgery (CSRF)](https://owasp.org/www-community/attacks/csrf) attacks exploit the fact that browsers automatically include cookies and other credentials when making requests to websites. An attacker can create a malicious website that makes requests to your GraphQL server using the victim's credentials. + +CSRF attacks are particularly dangerous for "simple" requests that don't trigger a CORS preflight check. These attacks can: +- Execute mutations using an authenticated user's credentials +- Extract timing information from queries (XS-Search attacks) +- Abuse any GraphQL operations that have side effects + +## How CSRF Prevention Works + +Mercurius protects against CSRF attacks by ensuring that GraphQL requests do **not** qualify as “simple” requests under the [CORS specification](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests). + +A request is considered safe if **any** of the following conditions are met: + +### 1. Content-Type Header + +Requests that include a `Content-Type` header specifying a type **other than**: + +* `text/plain` +* `application/x-www-form-urlencoded` +* `multipart/form-data` + +will trigger a **preflight `OPTIONS` request**, meaning the request cannot be considered “simple.” + +By default, Mercurius allows the following `Content-Type` headers: + +* `application/json` (recommended and most common) +* `application/graphql` + +Note charset and other params are ignored + +### 2. Required Headers + +Requests that include a **custom header** also require a **preflight `OPTIONS` request**, preventing them from being “simple.” + +By default, Mercurius checks for one of the following headers: + +* `X-Mercurius-Operation-Name` +* `Mercurius-Require-Preflight` + +## Configuration + +### Enabling CSRF Prevention + +CSRF prevention is **disabled by default**. Enable it with: + +```javascript +const app = Fastify() +await app.register(mercurius, { + schema, + resolvers, + csrfPrevention: true // Enable with default settings +}) +``` + +Default required headers (case insensitive): +- `x-mercurius-operation-name` - Custom header for identifying GraphQL operations +- `mercurius-require-preflight` - General-purpose header for forcing preflight + +### CORS Configuration + +While not strictly necessary, CORS should be configured appropriately: + +```javascript +await app.register(require('@fastify/cors'), { + origin: ['https://your-frontend.com'] +}) + +await app.register(mercurius, { + schema, + resolvers, + csrfPrevention: true +}) +``` + +## Advanced Configuration + +### Custom Required Headers + +Configure which headers are accepted to bypass CSRF protection (these replace the default headers): + +```javascript +await app.register(mercurius, { + schema, + resolvers, + csrfPrevention: { + contentTypes: ['application/json', 'application/graphql', 'application/vnd.api+json'], + requiredHeaders: ['Authorization', 'X-Custom-Header', 'X-Another-Header'] + } +}) +``` + +### Disabling CSRF Prevention + +```javascript +await app.register(mercurius, { + schema, + resolvers, + csrfPrevention: false +}) +``` + +### Enabling File Upload + +File uploads require a `multipart/form-data` request. To enable CSRF protection for file uploads, the request must include both: + +* `Content-Type: multipart/form-data` +* A custom header + +```javascript +import mercuriusUpload from 'mercurius-upload'; +import mercurius from 'mercurius'; + +await app.register(mercuriusUpload); +await app.register(mercurius, { + schema, + resolvers, + csrfPrevention: { + contentTypes: ['application/json', 'multipart/form-data'], + requiredHeaders: ['X-Custom-Header'] + } +}); +``` + +This configuration ensures that file uploads trigger a preflight `OPTIONS` request, preventing them from being treated as "simple" requests and keeping your API safe from CSRF attacks. + +## Client Integration + +For custom GraphQL clients, ensure your requests include one of the following: + +### Option 1: Use application/json content-type (recommended) +```javascript +fetch('/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ query: '{ hello }' }) +}) +``` + +### Option 2: Include a required header +```javascript +fetch('/graphql?query={hello}', { + method: 'GET', + headers: { + 'mercurius-require-preflight': 'true' + } +}) +``` + +## Complete Examples + +### Basic Server Setup + +```javascript +const Fastify = require('fastify') +const mercurius = require('mercurius') + +const app = Fastify({ logger: true }) + +const schema = ` + type Query { + hello: String + users: [User] + } + + type Mutation { + createUser(name: String!): User + } + + type User { + id: ID! + name: String! + } +` + +const resolvers = { + Query: { + hello: () => 'Hello World', + users: () => [{ id: '1', name: 'John' }] + }, + Mutation: { + createUser: (_, { name }) => ({ id: Date.now().toString(), name }) + } +} + +// Register CORS (recommended) +await app.register(require('@fastify/cors'), { + origin: ['https://your-frontend.com'], + credentials: true +}) + +// Register Mercurius with CSRF protection +await app.register(mercurius, { + schema, + resolvers, + csrfPrevention: true, // Enable CSRF protection +}) + +await app.listen({ port: 4000, host: '0.0.0.0' }) +console.log('GraphQL server running on http://localhost:4000/graphql') +``` + +### Frontend Client Example + +```javascript +// React/Frontend example with proper headers +const client = { + query: async (query, variables = {}) => { + const response = await fetch('/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Optional: Add custom identification + 'x-mercurius-operation-name': 'ClientQuery' + }, + body: JSON.stringify({ query, variables }) + }) + + if (!response.ok) { + throw new Error(`GraphQL Error: ${response.status}`) + } + + return response.json() + } +} + +// Usage +try { + const result = await client.query('{ hello }') + console.log(result.data.hello) +} catch (error) { + console.error('CSRF or other error:', error) +} +``` + +## Testing CSRF Prevention + +### Testing Blocked Requests + +```javascript +// This request will be blocked (400 status) +const response = await fetch('/graphql?query={hello}', { + method: 'GET' + // No required headers or valid content-type +}) +console.log(response.status) // 400 +``` + +### Testing Allowed Requests + +```javascript +// This request will succeed +const response = await fetch('/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ query: '{ hello }' }) +}) +console.log(response.status) // 200 +``` + +## Error Response + +When a request is blocked by CSRF prevention, you'll receive a 400 status with the following error: + +```json +{ + "data": null, + "errors": [{ + "message": "This operation has been blocked as a potential Cross-Site Request Forgery (CSRF)." + }] +} +``` + +## Migration Guide + +If you're adding CSRF prevention to an existing Mercurius application: + +### For Most Applications +✅ **No action required** - Most GraphQL clients already send appropriate headers. + +### If You See CSRF Errors +1. **Check your client** - Ensure it sends `Content-Type: application/json` for POST requests +2. **Add required headers** - For GET requests, add `mercurius-require-preflight: true` +3. **Configure custom headers** - If needed, add your client's headers to `requiredHeaders` + +### Legacy Client Support + +For clients that can't be easily updated: + +```javascript +await app.register(mercurius, { + schema, + resolvers, + csrfPrevention: { + requiredHeaders: [ + 'x-mercurius-operation-name', + 'mercurius-require-preflight', + 'User-Agent', // Many clients send this automatically + 'X-Requested-With' // Common in AJAX libraries + ] + } +}) +``` + +## Security Considerations + +### When CSRF Prevention is Critical +- Applications with authentication/authorization +- APIs that perform mutations or have side effects +- Public-facing GraphQL endpoints +- Applications handling sensitive data + +### When CSRF Prevention May Be Less Critical +- Public read-only APIs with no authentication +- Internal APIs on isolated networks +- Development environments (consider disabling temporarily) + +### Best Practices +1. **Keep CSRF prevention enabled** in production +2. **Use HTTPS** to prevent header manipulation +3. **Implement proper CORS policies** as an additional layer +4. **Monitor for blocked requests** to catch client issues +5. **Test thoroughly** when adding custom required headers + +## Troubleshooting + +### Common Issues + +**Q: My requests are being blocked with a 400 error** +A: Ensure your client sends `Content-Type: application/json` or add `mercurius-require-preflight: true` header. + +**Q: GraphiQL stopped working** +A: GraphiQL should work automatically. If not, check if you've misconfigured the routes or added overly restrictive headers. + +**Q: My frontend or mobile app requests are blocked** +A: Check the HTTP client configuration. Most modern clients work automatically, but ensure proper Content-Type headers. + +**Q: I need to support a legacy client** +A: Add the client's existing headers to `requiredHeaders`, or as a last resort, disable CSRF prevention. + +### Debug Mode + +To debug CSRF prevention issues, you can temporarily log requests: + +```javascript +app.addHook('preHandler', async (request, reply) => { + if (request.url.includes('/graphql')) { + console.log('GraphQL request headers:', request.headers) + console.log('Content-Type:', request.headers['content-type']) + } +}) +``` + +### Testing Your Configuration + +Create a simple test to verify CSRF protection is working: + +```javascript +// test-csrf.js +const test = async () => { + // This should be blocked + try { + const blocked = await fetch('http://localhost:4000/graphql?query={hello}') + console.log('CSRF test failed - request was not blocked:', blocked.status) + } catch (error) { + console.log('CSRF correctly blocked the request') + } + + // This should work + try { + const allowed = await fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: '{ hello }' }) + }) + console.log('Valid request succeeded:', allowed.status === 200) + } catch (error) { + console.log('Valid request failed:', error.message) + } +} + +test()
index.d.ts+8 −0 modified@@ -526,6 +526,14 @@ declare namespace mercurius { * - Increase body size limit for larger queries */ additionalRouteOptions?: Omit<RouteOptions, 'handler' | 'wsHandler' | 'method' | 'url'> + + /** + * Enable CSRF prevention + */ + csrfPrevention?: boolean | { + allowedContentTypes?: string[]; + requiredHeaders?: string[]; + }; } export type MercuriusOptions = MercuriusCommonOptions & (MercuriusSchemaOptions)
index.js+6 −1 modified@@ -44,6 +44,7 @@ const { onResolutionHandler, onExtendSchemaHandler } = require('./lib/handlers') +const { normalizeCSRFConfig } = require('./lib/csrf') async function buildCache (opts) { if (Object.prototype.hasOwnProperty.call(opts, 'cache')) { @@ -79,6 +80,9 @@ const mercurius = fp(async function (app, opts) { const queryDepthLimit = opts.queryDepth const errorFormatter = typeof opts.errorFormatter === 'function' ? opts.errorFormatter : defaultErrorFormatter + // Configure CSRF prevention + const csrfConfig = normalizeCSRFConfig(opts.csrfPrevention) + opts.graphql = opts.graphql || {} const gqlParseOpts = opts.graphql.parseOptions || {} const gqlValidateOpts = opts.graphql.validateOptions || {} @@ -212,7 +216,8 @@ const mercurius = fp(async function (app, opts) { subscriptionContextFn, keepAlive, fullWsTransport, - additionalRouteOptions: opts.additionalRouteOptions + additionalRouteOptions: opts.additionalRouteOptions, + csrfConfig }) }
lib/csrf.js+128 −0 added@@ -0,0 +1,128 @@ +'use strict' + +const { MER_ERR_GQL_CSRF_PREVENTION } = require('./errors') + +const CSRF_ERROR_MESSAGE = 'This operation has been blocked as a potential Cross-Site Request Forgery (CSRF).' +const defaultCSRFConfig = { + allowedContentTypes: ['application/json', 'application/graphql'], + requiredHeaders: ['x-mercurius-operation-name', 'mercurius-require-preflight'] +} + +/** + * Check if a Content-Type header indicates a non-simple request + * @param {string} contentType - The Content-Type header value + * @param {string[]} allowedContentTypes - The allowed content types + * @returns {boolean} - True if the content type makes the request non-simple + */ +function isValidContentType (contentType, allowedContentTypes) { + if (!contentType) return false + + const index = contentType.indexOf(';') + if (index === -1) { + return allowedContentTypes.includes(contentType) + } + + // Extract the main content type (ignore charset and other parameters) + const type = contentType.substring(0, index).trim().toLowerCase() + return allowedContentTypes.includes(type) +} + +/** + * Check if any of the required headers are present - note both are already lowercased + * @param {Object} headers - Request headers + * @param {string[]} requiredHeaders - Array of required header names + * @returns {boolean} - True if at least one required header is present + */ +function hasRequiredHeader (headers, requiredHeaders) { + for (let i = 0; i < requiredHeaders.length; i++) { + if (Object.hasOwn(headers, requiredHeaders[i])) { + return true + } + } +} + +/** + * Validate CSRF prevention configuration + * @param {Object} config - CSRF configuration + * @returns {Object} - Normalized configuration + */ +function normalizeCSRFConfig (config) { + if (config === true) { + return defaultCSRFConfig + } + + if (!config) { + return undefined + } + + const normalized = {} + + let multipart = false + if (config.requiredHeaders) { + if (!Array.isArray(config.requiredHeaders)) { + throw new Error('csrfPrevention.requiredHeaders must be an array') + } + normalized.requiredHeaders = config.requiredHeaders.map(h => h.toLowerCase()) + } else { + normalized.requiredHeaders = defaultCSRFConfig.requiredHeaders + } + + if (config.allowedContentTypes) { + if (!Array.isArray(config.allowedContentTypes)) { + throw new Error('csrfPrevention.allowedContentTypes must be an array') + } + normalized.allowedContentTypes = config.allowedContentTypes.map(h => { + if (h === 'multipart/form-data') { + multipart = true + } + return h.toLowerCase() + }) + } else { + normalized.allowedContentTypes = defaultCSRFConfig.allowedContentTypes + } + + multipart && (normalized.multipart = true) + + return normalized +} + +/** + * Perform CSRF prevention check + * @param {Object} request - Fastify request object + * @param {Object} config - CSRF configuration + * @throws {MER_ERR_GQL_CSRF_PREVENTION} - If CSRF check fails + */ +function checkCSRFPrevention (request, config) { + // Check 1: Content-Type header indicates non-simple request + if (isValidContentType(request.headers['content-type'], config.allowedContentTypes)) { + if (config.multipart) { + if (hasRequiredHeader(request.headers, config.requiredHeaders)) { + return // Request is safe + } else { + const err = new MER_ERR_GQL_CSRF_PREVENTION() + err.message = CSRF_ERROR_MESSAGE + throw err + } + } + + return // Request is safe + } + + // Check 2: Required headers are present + if (hasRequiredHeader(request.headers, config.requiredHeaders)) { + return // Request is safe + } + + // Request failed CSRF prevention checks + const err = new MER_ERR_GQL_CSRF_PREVENTION() + err.message = CSRF_ERROR_MESSAGE + throw err +} + +module.exports = { + normalizeCSRFConfig, + checkCSRFPrevention, + isValidContentType, + hasRequiredHeader, + CSRF_ERROR_MESSAGE +}
lib/errors.js+5 −0 modified@@ -134,6 +134,11 @@ const errors = { 'MER_ERR_GQL_QUERY_DEPTH', '`%s query depth (%s) exceeds the query depth limit of %s`' ), + MER_ERR_GQL_CSRF_PREVENTION: createError( + 'MER_ERR_GQL_CSRF_PREVENTION', + '%s', + 400 + ), /** * Persisted query errors */
lib/routes.js+10 −1 modified@@ -13,6 +13,7 @@ const { MER_ERR_GQL_VALIDATION, toGraphQLError } = require('./errors') +const { checkCSRFPrevention, normalizeCSRFConfig } = require('./csrf') const responseProperties = { data: { @@ -224,6 +225,8 @@ module.exports = async function (app, opts) { normalizedRouteOptions.wsHandler = undefined } + const csrfConfig = normalizeCSRFConfig(opts.csrfConfig) + async function executeQuery (query, variables, operationName, request, reply) { // Validate a query is present if (!query) { @@ -308,6 +311,9 @@ module.exports = async function (app, opts) { schema: getSchema, attachValidation: true, ...normalizedRouteOptions, + onRequest: async function (request, reply) { + csrfConfig && checkCSRFPrevention(request, csrfConfig) + }, handler: async function (request, reply) { // Generate the context for this request if (contextFn) { @@ -354,7 +360,10 @@ module.exports = async function (app, opts) { app.post(graphqlPath, { schema: postSchema(allowBatchedQueries), attachValidation: true, - ...normalizedRouteOptions + ...normalizedRouteOptions, + onRequest: async function (request, reply) { + csrfConfig && checkCSRFPrevention(request, csrfConfig) + }, }, async function (request, reply) { // Generate the context for this request if (contextFn) {
README.md+1 −0 modified@@ -39,6 +39,7 @@ Features: - [GraphQL over WebSocket](/docs/graphql-over-websocket.md) - [Integrations](docs/integrations/) - [Related Plugins](docs/plugins.md) +- [Security - CSRF Prevention](docs/security/csrf-prevention.md) - [Faq](/docs/faq.md) - [Acknowledgements](#acknowledgements) - [License](#license)
test/csrf.test.js+734 −0 added@@ -0,0 +1,734 @@ +'use strict' + +const { test } = require('node:test') +const Fastify = require('fastify') +const GQL = require('..') +const { CSRF_ERROR_MESSAGE } = require('../lib/csrf') + +// Helper to create a minimal schema and resolvers for testing +const createTestSchema = () => ({ + schema: ` + type Query { + hello: String + } + type Mutation { + setMessage(message: String): String + } + `, + resolvers: { + Query: { + hello: () => 'Hello World' + }, + Mutation: { + setMessage: (_, { message }) => message + } + } +}) + +test('CSRF disabled - simple GET request should work', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: false + }) + + const res = await app.inject({ + method: 'GET', + url: '/graphql?query={hello}' + }) + + t.assert.equal(res.statusCode, 200) + t.assert.deepEqual(JSON.parse(res.body), { + data: { hello: 'Hello World' } + }) +}) + +test('CSRF enabled - simple GET request should be blocked', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true + }) + + const res = await app.inject({ + method: 'GET', + url: '/graphql?query={hello}' + }) + + t.assert.equal(res.statusCode, 400) + const body = JSON.parse(res.body) + t.assert.equal(body.errors[0].message, CSRF_ERROR_MESSAGE) + t.assert.equal(body.data, null) +}) + +test('CSRF enabled - POST with application/json should work', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: '{ hello }' }) + }) + + t.assert.equal(res.statusCode, 200) + t.assert.deepEqual(JSON.parse(res.body), { + data: { hello: 'Hello World' } + }) +}) + +test('CSRF enabled - POST with application/graphql should work', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'application/graphql' }, + body: '{ hello }' + }) + + t.assert.equal(res.statusCode, 200) + t.assert.deepEqual(JSON.parse(res.body), { + data: { hello: 'Hello World' } + }) +}) + +test('CSRF enabled - GET with required header should work', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true + }) + + const res = await app.inject({ + method: 'GET', + url: '/graphql?query={hello}', + headers: { 'mercurius-require-preflight': 'true' } + }) + + t.assert.equal(res.statusCode, 200) + t.assert.deepEqual(JSON.parse(res.body), { + data: { hello: 'Hello World' } + }) +}) + +test('CSRF enabled - GET with x-mercurius-operation-name header should work', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true + }) + + const res = await app.inject({ + method: 'GET', + url: '/graphql?query={hello}', + headers: { 'x-mercurius-operation-name': 'test' } + }) + + t.assert.equal(res.statusCode, 200) + t.assert.deepEqual(JSON.parse(res.body), { + data: { hello: 'Hello World' } + }) +}) + +test('CSRF enabled - headers should be case insensitive', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true + }) + + const res = await app.inject({ + method: 'GET', + url: '/graphql?query={hello}', + headers: { 'X-Mercurius-Operation-Name': 'test' } + }) + + t.assert.equal(res.statusCode, 200) + t.assert.deepEqual(JSON.parse(res.body), { + data: { hello: 'Hello World' } + }) +}) + +test('CSRF enabled - content type with charset should work', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ query: '{ hello }' }) + }) + + t.assert.equal(res.statusCode, 200) + t.assert.deepEqual(JSON.parse(res.body), { + data: { hello: 'Hello World' } + }) +}) + +test('CSRF enabled - POST with text/plain should be blocked', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'text/plain' }, + body: '{ hello }' + }) + + t.assert.equal(res.statusCode, 400) + const body = JSON.parse(res.body) + t.assert.equal(body.errors[0].message, CSRF_ERROR_MESSAGE) +}) + +test('CSRF enabled - GET with simple content type should be blocked', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true + }) + + const res = await app.inject({ + method: 'GET', + url: '/graphql?query={hello}', + headers: { 'content-type': 'application/x-www-form-urlencoded' } + }) + + t.assert.equal(res.statusCode, 400) + const body = JSON.parse(res.body) + t.assert.equal(body.errors[0].message, CSRF_ERROR_MESSAGE) +}) + +test('CSRF custom config - custom header should work', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: { + allowedContentTypes: ['application/json'], + requiredHeaders: ['authorization', 'x-custom-header'] + } + }) + + const res = await app.inject({ + method: 'GET', + url: '/graphql?query={hello}', + headers: { authorization: 'Bearer token' } + }) + + t.assert.equal(res.statusCode, 200) + t.assert.deepEqual(JSON.parse(res.body), { + data: { hello: 'Hello World' } + }) +}) + +test('CSRF custom config - request without valid content type or header should be blocked', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: { + allowedContentTypes: ['application/json'], + requiredHeaders: ['authorization'] + } + }) + + const res = await app.inject({ + method: 'GET', + url: '/graphql?query={hello}' + }) + + t.assert.equal(res.statusCode, 400) + const body = JSON.parse(res.body) + t.assert.equal(body.errors[0].message, CSRF_ERROR_MESSAGE) +}) + +test('CSRF enabled - POST request with variables should work', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + query: 'mutation($msg: String) { setMessage(message: $msg) }', + variables: { msg: 'test message' } + }) + }) + + t.assert.equal(res.statusCode, 200) + t.assert.deepEqual(JSON.parse(res.body), { + data: { setMessage: 'test message' } + }) +}) + +test('CSRF enabled - mutation over GET should be blocked by method validation', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true + }) + + const res = await app.inject({ + method: 'GET', + url: '/graphql?query=mutation{setMessage(message:"test")}', + headers: { 'mercurius-require-preflight': 'true' } + }) + + // This should return 405 (method not allowed) rather than 400 (CSRF error) + t.assert.equal(res.statusCode, 405) +}) + +test('CSRF enabled - custom path should work', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + path: '/api/graphql', + csrfPrevention: true + }) + + // CSRF protection should work with custom path - block simple request + const res1 = await app.inject({ + method: 'GET', + url: '/api/graphql?query={hello}' + }) + + t.assert.equal(res1.statusCode, 400) + + // Should work with valid header + const res2 = await app.inject({ + method: 'GET', + url: '/api/graphql?query={hello}', + headers: { 'mercurius-require-preflight': 'true' } + }) + + t.assert.equal(res2.statusCode, 200) +}) + +test('CSRF enabled - multiple allowed content types', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: { + allowedContentTypes: ['application/json', 'application/graphql', 'application/vnd.api+json'] + } + }) + + // application/json should work + const res1 = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: '{ hello }' }) + }) + + t.assert.equal(res1.statusCode, 200) + t.assert.deepEqual(JSON.parse(res1.body), { + data: { hello: 'Hello World' } + }) + + // application/graphql should work + const res2 = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'application/graphql' }, + body: '{ hello }' + }) + + t.assert.equal(res2.statusCode, 200) + t.assert.deepEqual(JSON.parse(res2.body), { + data: { hello: 'Hello World' } + }) + + // text/plain should be blocked since it's not in allowed types + const res3 = await app.inject({ + method: 'GET', + url: '/graphql?query={hello}' + }) + + t.assert.equal(res3.statusCode, 400) + const body = JSON.parse(res3.body) + t.assert.equal(body.errors[0].message, CSRF_ERROR_MESSAGE) +}) + +test('CSRF enabled - multipart form data with required header should work', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: { + allowedContentTypes: ['application/json', 'multipart/form-data'], + requiredHeaders: ['x-custom-header'] + } + }) + + const res = await app.inject({ + method: 'GET', + url: '/graphql?query={hello}', + headers: { + 'content-type': 'multipart/form-data', + 'x-custom-header': 'value' + } + }) + + t.assert.equal(res.statusCode, 200) + t.assert.deepEqual(JSON.parse(res.body), { + data: { hello: 'Hello World' } + }) +}) + +test('CSRF enabled - multipart form data without required header should be blocked', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: { + allowedContentTypes: ['application/json', 'multipart/form-data'], + requiredHeaders: ['x-custom-header'] + } + }) + + const res = await app.inject({ + method: 'GET', + url: '/graphql?query={hello}', + headers: { 'content-type': 'multipart/form-data' } + }) + + t.assert.equal(res.statusCode, 400) + const body = JSON.parse(res.body) + t.assert.equal(body.errors[0].message, CSRF_ERROR_MESSAGE) +}) + +test('CSRF enabled - batch queries should work with proper content type', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true, + allowBatchedQueries: true + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify([ + { query: '{ hello }' }, + { query: '{ hello }' } + ]) + }) + + t.assert.equal(res.statusCode, 200) + const body = JSON.parse(res.body) + t.assert.equal(Array.isArray(body), true) + t.assert.equal(body.length, 2) + t.assert.deepEqual(body[0], { data: { hello: 'Hello World' } }) + t.assert.deepEqual(body[1], { data: { hello: 'Hello World' } }) +}) + +test('CSRF enabled - invalid JSON should be handled properly', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: true + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'application/json' }, + body: 'invalid json' + }) + + t.assert.equal(res.statusCode, 400) + // Should be a JSON parse error, not CSRF error + const body = JSON.parse(res.body) + t.assert.notEqual(body.errors[0].message, CSRF_ERROR_MESSAGE) +}) + +test('CSRF config - allowedContentTypes without multipart/form-data', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + // Test configuration that doesn't include multipart/form-data + // This should cover the else branch in the map function (line 73) + app.register(GQL, { + schema, + resolvers, + csrfPrevention: { + allowedContentTypes: ['application/json', 'application/graphql', 'text/plain'] + } + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: '{ hello }' }) + }) + + t.assert.equal(res.statusCode, 200) + t.assert.deepEqual(JSON.parse(res.body), { + data: { hello: 'Hello World' } + }) +}) + +test('CSRF config - allowedContentTypes with lowercase conversion', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + // Test configuration with content types that need lowercase conversion + // This should cover the else branch in the map function (line 73) + app.register(GQL, { + schema, + resolvers, + csrfPrevention: { + allowedContentTypes: ['APPLICATION/JSON', 'APPLICATION/GRAPHQL'] + } + }) + + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'application/json' }, // lowercase content-type header + body: JSON.stringify({ query: '{ hello }' }) + }) + + t.assert.equal(res.statusCode, 200) + t.assert.deepEqual(JSON.parse(res.body), { + data: { hello: 'Hello World' } + }) +}) + +test('CSRF - multipart without required header edge case', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: { + allowedContentTypes: ['multipart/form-data'], + requiredHeaders: ['x-required-header'] + } + }) + + // Test multipart content type without required header - should throw CSRF error + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'multipart/form-data' }, + body: 'some form data' + }) + + t.assert.equal(res.statusCode, 400) + const body = JSON.parse(res.body) + t.assert.equal(body.errors[0].message, CSRF_ERROR_MESSAGE) +}) + +test('CSRF config - default allowedContentTypes when not specified', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + // Test configuration that only specifies requiredHeaders, not allowedContentTypes + // This should use the default allowedContentTypes (lines 81-82) + app.register(GQL, { + schema, + resolvers, + csrfPrevention: { + requiredHeaders: ['authorization'] + } + }) + + // Should work with default content type + const res1 = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: '{ hello }' }) + }) + + t.assert.equal(res1.statusCode, 200) + t.assert.deepEqual(JSON.parse(res1.body), { + data: { hello: 'Hello World' } + }) + + // Should also work with other default content type + const res2 = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'application/graphql' }, + body: '{ hello }' + }) + + t.assert.equal(res2.statusCode, 200) + t.assert.deepEqual(JSON.parse(res2.body), { + data: { hello: 'Hello World' } + }) +}) + +test('CSRF - multipart config with explicit multipart flag', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + app.register(GQL, { + schema, + resolvers, + csrfPrevention: { + allowedContentTypes: ['application/json', 'multipart/form-data'], + requiredHeaders: ['x-required-header'] + } + }) + + // Test multipart content type without required header - should hit lines 97-98 + const res = await app.inject({ + method: 'POST', + url: '/graphql', + headers: { 'content-type': 'multipart/form-data' }, + body: 'form data' + }) + + t.assert.equal(res.statusCode, 400) + const body = JSON.parse(res.body) + t.assert.equal(body.errors[0].message, CSRF_ERROR_MESSAGE) +}) + +test('CSRF config - invalid requiredHeaders type should throw error', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + // Test configuration with invalid requiredHeaders (not an array) - should hit lines 63-64 + await t.assert.rejects(async () => { + await app.register(GQL, { + schema, + resolvers, + csrfPrevention: { + requiredHeaders: 'not-an-array' + } + }) + await app.ready() + }, { + message: 'csrfPrevention.requiredHeaders must be an array' + }) +}) + +test('CSRF config - invalid allowedContentTypes type should throw error', async (t) => { + const app = Fastify() + const { schema, resolvers } = createTestSchema() + + // Test configuration with invalid allowedContentTypes (not an array) - should hit lines 72-73 + await t.assert.rejects(async () => { + await app.register(GQL, { + schema, + resolvers, + csrfPrevention: { + allowedContentTypes: 'not-an-array' + } + }) + await app.ready() + }, { + message: 'csrfPrevention.allowedContentTypes must be an array' + }) +}) + +test('CSRF - multipart content type without header triggers specific error path', async (t) => { + const { normalizeCSRFConfig, checkCSRFPrevention } = require('../lib/csrf') + const { MER_ERR_GQL_CSRF_PREVENTION } = require('../lib/errors') + + // Test the multipart-specific error path directly (lines 97-98) + const config = normalizeCSRFConfig({ + allowedContentTypes: ['multipart/form-data'], + requiredHeaders: ['x-csrf-token'] + }) + + const request = { + headers: { + 'content-type': 'multipart/form-data' + // Missing the required x-csrf-token header + } + } + + // This should hit lines 97-98 specifically + try { + checkCSRFPrevention(request, config) + t.fail('Should have thrown CSRF error') + } catch (err) { + t.assert.equal(err.constructor, MER_ERR_GQL_CSRF_PREVENTION) + t.assert.equal(err.message, CSRF_ERROR_MESSAGE) + } +})
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-v66j-6wwf-jc57ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-64166ghsaADVISORY
- github.com/mercurius-js/mercurius/commit/962d402ec7a92342f4a1b7f5f04af01776838c3cghsax_refsource_MISCWEB
- github.com/mercurius-js/mercurius/pull/1187ghsax_refsource_MISCWEB
- github.com/mercurius-js/mercurius/security/advisories/GHSA-v66j-6wwf-jc57ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.