Skip to content

Commit ada1200

Browse files
glasserclaude
andauthored
Reject GET requests with a Content-Type other than application/json (#8191)
GET requests carry their GraphQL operation in query parameters, not a body, so there is no legitimate reason to send any Content-Type other than application/json (which Apollo Client Web sends by default). Reject any other value — including valid MIME types like text/plain and application/graphql, as well as malformed values — with HTTP 415. This also addresses GHSA-9q82-xgwf-vj6h <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Security** * Strengthened CSRF protection: GET requests with `Content-Type` headers other than `application/json` are now rejected (HTTP 415) * Users with cookies or HTTP Basic Auth should upgrade to v5.5.0 * **Documentation** * Updated request handling and CSRF prevention documentation <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e66bd0b commit ada1200

File tree

7 files changed

+153
-9
lines changed

7 files changed

+153
-9
lines changed

.changeset/chatty-worlds-vanish.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@apollo/server-integration-testsuite': minor
3+
'@apollo/server': minor
4+
---
5+
6+
⚠️ SECURITY `@apollo/server/standalone`:
7+
8+
Apollo Server now rejects GraphQL `GET` requests which contain a `Content-Type` header other than `application/json` (with optional parameters such as `; charset=utf-8`). Any other value is now rejected with a 415 status code.
9+
10+
(GraphQL `GET` requests without a `Content-Type` header are still allowed, though they do still need to contain a non-empty `X-Apollo-Operation-Name` or `Apollo-Require-Preflight` header to be processed if the default CSRF prevention feature is enabled.)
11+
12+
This improvement makes Apollo Server's CSRF more resistant to browsers which implement CORS in non-spec-compliant ways. Apollo is aware of one browser which as of March 2026 has a bug which allows an attacker to circumvent Apollo Server's CSRF prevention feature to carry out read-only XS-Search-style CSRF attacks. The browser vendor is in the process of patching this vulnerability; upgrading Apollo Server to v5.5.0 mitigates this vulnerability.
13+
14+
**If your server uses cookies (or HTTP Basic Auth) for authentication, Apollo encourages you to upgrade to v5.5.0.**
15+
16+
This is technically a backwards-incompatible change. Apollo is not aware of any GraphQL clients which provide non-empty `Content-Type` headers with `GET` requests with types other than `application/json`. If your use case requires such requests, please [file an issue](https://github.com/apollographql/apollo-server/issues) and we may add more configurability in a follow-up release.
17+
18+
See [advisory GHSA-9q82-xgwf-vj6h](https://github.com/apollographql/apollo-server/security/advisories/GHSA-9q82-xgwf-vj6h) for more details.

docs/source/integrations/building-integrations.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ Integrations _are_ responsible for parsing a request's body and using the values
120120

121121
In Apollo Server's Express integration, you set up the `express.json()` JSON middleware, which handles parsing JSON request bodies with a `content-type` of `application/json`. Integrations can require a similar middleware (or plugin) for their ecosystem, or they can handle body parsing themselves.
122122

123+
Note that Apollo Server's [CSRF prevention](../security/cors/#preventing-cross-site-request-forgery-csrf) feature assumes that your integration's recommended method of body parsing will reject `POST` requests without a `content-type` of `application/json`. If your integration allows `POST` requests with no `content-type` header (or with a `content-type` of `text/plain`, `application/x-www-form-urlencoded`, or `multipart/form-data`), servers will be vulnerable to CSRF attacks.
124+
123125
For example, a correctly parsed body should have a shape resembling this:
124126

125127
```ts

docs/source/security/cors.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ However, Apollo Server also handles [`GET` requests](../workflow/requests#get-re
200200

201201
By default, Apollo Server 4+ has a CSRF prevention feature enabled. This means your server only executes GraphQL operations if at least one of the following conditions is true:
202202

203-
- The incoming request includes a `Content-Type` header that specifies a type other than `text/plain`, `application/x-www-form-urlencoded`, or `multipart/form-data`. Notably, a `Content-Type` of `application/json` (including any suffix like `application/json; charset=utf-8`) is sufficient. This means that all `POST` requests (which must use `Content-Type: application/json`) will be executed. Additionally, all versions of [Apollo Client Web](/react/api/link/apollo-link-http) that support `GET` requests do include `Content-Type: application/json` headers, so any request from Apollo Client Web (`POST` or `GET`) will be executed.
203+
- The incoming request includes a `Content-Type` header that specifies a type other than `text/plain`, `application/x-www-form-urlencoded`, or `multipart/form-data`. Notably, a `Content-Type` of `application/json` (including any suffix like `application/json; charset=utf-8`) is sufficient. This means that all `POST` requests (which must use `Content-Type: application/json`) will be executed. Additionally, all versions of [Apollo Client Web](/react/api/link/apollo-link-http) that support `GET` requests do include `Content-Type: application/json` headers, so any request from Apollo Client Web (`POST` or `GET`) will be executed. (As of v5.5.0, Apollo Server rejects GET requests which contain a non-empty `Content-Type` other than `application/json`, so in practice this bullet point could be summarized as "the incoming request includes a `Content-Type` header that specifies the type `application/json`".)
204204
- There is a non-empty `X-Apollo-Operation-Name` header. This header is sent with all operations (`POST` or `GET`) by [Apollo iOS](/ios) (v0.13.0+) and [Apollo Kotlin](/kotlin) (all versions, including its former name "Apollo Android"), so any request from Apollo iOS or Apollo Kotlin will be executed.
205205
- There is a non-empty `Apollo-Require-Preflight` header.
206206

docs/source/workflow/requests.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ curl --request GET \
8282
https://rover.apollo.dev/quickstart/products/graphql?query=query%20GetBestSellers%28%24category%3A%20ProductCategory%29%7BbestSellers%28category%3A%20%24category%29%7Btitle%7D%7D&operationName=GetBestSellers&variables=%7B%22category%22%3A%22BOOKS%22%7D
8383
```
8484

85-
Unlike with `POST` requests, `GET` requests do not require a `Content-Type` header. However, if you have Apollo Server's default [CSRF prevention](../security/cors#preventing-cross-site-request-forgery-csrf) feature enabled, `GET` requests that don't contain a `Content-Type` header must contain one of the following:
85+
Unlike with `POST` requests, `GET` requests do not require a `Content-Type` header: this header describes the request's body's type, and `GET` requests do not have a body. For historical reasons, Apollo Server allows `GET` requests with a `Content-Type` header of `application/json` (with optional parameters such as `; charset=utf-8`); any other value is rejected with a 415 status code.
86+
87+
Additionally, if you have Apollo Server's default [CSRF prevention](../security/cors#preventing-cross-site-request-forgery-csrf) feature enabled, `GET` requests that don't contain a `Content-Type` header must contain one of the following:
8688

8789
- A non-empty `X-Apollo-Operation-Name` header
8890
- A non-empty `Apollo-Require-Preflight` header

packages/integration-testsuite/src/apolloServerTests.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3177,15 +3177,21 @@ export function defineIntegrationTestSuiteApolloServerTests(
31773177
.query(operation),
31783178
);
31793179

3180-
// GET with an invalid content-type (no slash) actually succeeds, since
3181-
// this will be preflighted, although it would be reasonable if it
3182-
// didn't.
3183-
succeeds(
3184-
await request(url)
3180+
// GET with an invalid content-type (no slash) is blocked (not for CSRF
3181+
// reasons specifically, but because the content-type is not parsable
3182+
// as application/json).
3183+
{
3184+
const res = await request(url)
31853185
.get('/')
31863186
.set('content-type', 'invalid')
3187-
.query(operation),
3188-
);
3187+
.query(operation);
3188+
expect(res.status).toBe(415);
3189+
expect(res.text).toMatch(/GET requests may not have a content-type/);
3190+
expect(invalidRequestErrors).toHaveLength(1);
3191+
expect(invalidRequestErrors.pop()?.message).toMatch(
3192+
/GET requests may not have a content-type/,
3193+
);
3194+
}
31893195

31903196
// Adding parameters to the content-type and spaces doesn't stop it from
31913197
// being blocked.

packages/integration-testsuite/src/httpServerTests.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,107 @@ export function defineIntegrationTestSuiteHttpServerTests(
605605
`);
606606
});
607607

608+
it('throws an error if GET request has a non-application/json content-type', async () => {
609+
const app = await createApp();
610+
const res = await request(app)
611+
.get('/?query={testString}')
612+
.set('apollo-require-preflight', 't')
613+
.set('content-type', 'text/plain');
614+
expect(res.status).toEqual(415);
615+
expect(JSON.parse((res.error as HTTPError).text))
616+
.toMatchInlineSnapshot(`
617+
{
618+
"errors": [
619+
{
620+
"extensions": {
621+
"code": "BAD_REQUEST",
622+
},
623+
"message": "GET requests may not have a content-type header other than application/json.",
624+
},
625+
],
626+
}
627+
`);
628+
});
629+
630+
it('throws an error if GET request has a non-preflighted non-application/json content-type', async () => {
631+
// application/graphql is a valid MIME type that is not one of the three
632+
// CORS-safelisted types, so CSRF prevention would allow it through (it
633+
// triggers a preflight). We block it anyway.
634+
const app = await createApp();
635+
const res = await request(app)
636+
.get('/?query={testString}')
637+
.set('apollo-require-preflight', 't')
638+
.set('content-type', 'application/graphql');
639+
expect(res.status).toEqual(415);
640+
expect(JSON.parse((res.error as HTTPError).text))
641+
.toMatchInlineSnapshot(`
642+
{
643+
"errors": [
644+
{
645+
"extensions": {
646+
"code": "BAD_REQUEST",
647+
},
648+
"message": "GET requests may not have a content-type header other than application/json.",
649+
},
650+
],
651+
}
652+
`);
653+
});
654+
655+
it('throws an error if GET request has a malformed content-type', async () => {
656+
const app = await createApp();
657+
const res = await request(app)
658+
.get('/?query={testString}')
659+
.set('apollo-require-preflight', 't')
660+
.set('content-type', 'invalid');
661+
expect(res.status).toEqual(415);
662+
expect(JSON.parse((res.error as HTTPError).text))
663+
.toMatchInlineSnapshot(`
664+
{
665+
"errors": [
666+
{
667+
"extensions": {
668+
"code": "BAD_REQUEST",
669+
},
670+
"message": "GET requests may not have a content-type header other than application/json.",
671+
},
672+
],
673+
}
674+
`);
675+
});
676+
677+
it('allows GET requests with content-type application/json', async () => {
678+
const app = await createApp();
679+
const expected = {
680+
testString: 'it works',
681+
};
682+
const query = {
683+
query: 'query test{ testString }',
684+
};
685+
const res = await request(app)
686+
.get('/')
687+
.set('content-type', 'application/json')
688+
.query(query);
689+
expect(res.status).toEqual(200);
690+
expect(res.body.data).toEqual(expected);
691+
});
692+
693+
it('allows GET requests with content-type application/json with charset parameter', async () => {
694+
const app = await createApp();
695+
const expected = {
696+
testString: 'it works',
697+
};
698+
const query = {
699+
query: 'query test{ testString }',
700+
};
701+
const res = await request(app)
702+
.get('/')
703+
.set('content-type', 'application/json; charset=utf-8')
704+
.query(query);
705+
expect(res.status).toEqual(200);
706+
expect(res.body.data).toEqual(expected);
707+
});
708+
608709
it('can handle a basic GET request', async () => {
609710
const app = await createApp();
610711
const expected = {

packages/server/src/runHttpQuery.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { type FormattedExecutionResult, Kind } from 'graphql';
2121
import { BadRequestError } from './internalErrorClasses.js';
2222
import Negotiator from 'negotiator';
2323
import { HeaderMap } from './utils/HeaderMap.js';
24+
import MIMEType from 'whatwg-mimetype';
2425

2526
function fieldIfString(
2627
o: Record<string, unknown>,
@@ -195,6 +196,20 @@ export async function runHttpQuery<TContext extends BaseContext>({
195196
}
196197

197198
case 'GET': {
199+
const contentType = httpRequest.headers.get('content-type');
200+
if (contentType !== undefined) {
201+
const contentTypeParsed = MIMEType.parse(contentType);
202+
if (
203+
contentTypeParsed === null ||
204+
contentTypeParsed.essence !== 'application/json'
205+
) {
206+
throw new BadRequestError(
207+
'GET requests may not have a content-type header other than application/json.',
208+
{ extensions: { http: newHTTPGraphQLHead(415) } },
209+
);
210+
}
211+
}
212+
198213
const searchParams = new URLSearchParams(httpRequest.search);
199214

200215
graphQLRequest = {

0 commit comments

Comments
 (0)