fix: Upgrade to GraphQL Apollo Server 5 and restrict GraphQL introspection (#9888)
BREAKING CHANGE: Upgrade to Apollo Server 5 and GraphQL express 5 integration; GraphQL introspection now requires using `masterKey` or setting `graphQLPublicIntrospection: true`.
This commit is contained in:
1063
package-lock.json
generated
1063
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,8 @@
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@apollo/server": "4.12.1",
|
||||
"@apollo/server": "5.0.0",
|
||||
"@as-integrations/express5": "1.1.2",
|
||||
"@graphql-tools/merge": "9.0.24",
|
||||
"@graphql-tools/schema": "10.0.23",
|
||||
"@graphql-tools/utils": "10.8.6",
|
||||
|
||||
@@ -748,10 +748,223 @@ describe('ParseGraphQLServer', () => {
|
||||
})
|
||||
expect(introspection.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should block __type introspection without master key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query TypeIntrospection {
|
||||
__type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
fail('should have thrown an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Response not successful: Received status code 403');
|
||||
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should block aliased __type introspection without master key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
query AliasedTypeIntrospection {
|
||||
myAlias: __type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
fail('should have thrown an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Response not successful: Received status code 403');
|
||||
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should block __type introspection in fragments without master key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
fragment TypeIntrospectionFields on Query {
|
||||
typeInfo: __type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
|
||||
query FragmentTypeIntrospection {
|
||||
...TypeIntrospectionFields
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
fail('should have thrown an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Response not successful: Received status code 403');
|
||||
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should block __type introspection through nested fragment spreads without master key', async () => {
|
||||
try {
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
fragment InnerFragment on Query {
|
||||
__type(name: "User") {
|
||||
name
|
||||
fields {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment OuterFragment on Query {
|
||||
...InnerFragment
|
||||
}
|
||||
|
||||
query NestedFragmentIntrospection {
|
||||
...OuterFragment
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
fail('should have thrown an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Response not successful: Received status code 403');
|
||||
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should block __type introspection hidden in fragment with valid field without master key', async () => {
|
||||
try {
|
||||
// First create a test object to query
|
||||
const object = new Parse.Object('SomeClass');
|
||||
await object.save();
|
||||
|
||||
await apolloClient.query({
|
||||
query: gql`
|
||||
fragment MixedFragment on Query {
|
||||
someClasses {
|
||||
edges {
|
||||
node {
|
||||
objectId
|
||||
}
|
||||
}
|
||||
}
|
||||
__type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
|
||||
query MixedQuery {
|
||||
...MixedFragment
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
fail('should have thrown an error');
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual('Response not successful: Received status code 403');
|
||||
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow __type introspection with master key', async () => {
|
||||
const introspection = await apolloClient.query({
|
||||
query: gql`
|
||||
query TypeIntrospection {
|
||||
__type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(introspection.data).toBeDefined();
|
||||
expect(introspection.data.__type).toBeDefined();
|
||||
expect(introspection.errors).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow aliased __type introspection with master key', async () => {
|
||||
const introspection = await apolloClient.query({
|
||||
query: gql`
|
||||
query AliasedTypeIntrospection {
|
||||
myAlias: __type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Master-Key': 'test',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(introspection.data).toBeDefined();
|
||||
expect(introspection.data.myAlias).toBeDefined();
|
||||
expect(introspection.errors).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow __type introspection with maintenance key', async () => {
|
||||
const introspection = await apolloClient.query({
|
||||
query: gql`
|
||||
query TypeIntrospection {
|
||||
__type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
`,
|
||||
context: {
|
||||
headers: {
|
||||
'X-Parse-Maintenance-Key': 'test2',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(introspection.data).toBeDefined();
|
||||
expect(introspection.data.__type).toBeDefined();
|
||||
expect(introspection.errors).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow __type introspection when public introspection is enabled', async () => {
|
||||
const parseServer = await reconfigureServer();
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
|
||||
const introspection = await apolloClient.query({
|
||||
query: gql`
|
||||
query TypeIntrospection {
|
||||
__type(name: "User") {
|
||||
name
|
||||
kind
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
expect(introspection.data).toBeDefined();
|
||||
expect(introspection.data.__type).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Default Types', () => {
|
||||
beforeEach(async () => {
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
});
|
||||
it('should have Object scalar type', async () => {
|
||||
const objectType = (
|
||||
await apolloClient.query({
|
||||
@@ -911,6 +1124,10 @@ describe('ParseGraphQLServer', () => {
|
||||
});
|
||||
|
||||
describe('Relay Specific Types', () => {
|
||||
beforeEach(async () => {
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
});
|
||||
|
||||
let clearCache;
|
||||
beforeEach(async () => {
|
||||
if (!clearCache) {
|
||||
@@ -1454,6 +1671,9 @@ describe('ParseGraphQLServer', () => {
|
||||
});
|
||||
|
||||
describe('Parse Class Types', () => {
|
||||
beforeEach(async () => {
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
});
|
||||
it('should have all expected types', async () => {
|
||||
await parseServer.config.databaseController.loadSchema();
|
||||
|
||||
@@ -1565,6 +1785,7 @@ describe('ParseGraphQLServer', () => {
|
||||
beforeEach(async () => {
|
||||
await parseGraphQLServer.setGraphQLConfig({});
|
||||
await resetGraphQLCache();
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
});
|
||||
|
||||
it_id('d6a23a2f-ca18-4b15-bc73-3e636f99e6bc')(it)('should only include types in the enabledForClasses list', async () => {
|
||||
@@ -8141,6 +8362,9 @@ describe('ParseGraphQLServer', () => {
|
||||
});
|
||||
|
||||
describe('Functions Mutations', () => {
|
||||
beforeEach(async () => {
|
||||
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
|
||||
});
|
||||
it('can be called', async () => {
|
||||
try {
|
||||
const clientMutationId = uuidv4();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import corsMiddleware from 'cors';
|
||||
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js';
|
||||
import { ApolloServer } from '@apollo/server';
|
||||
import { expressMiddleware } from '@apollo/server/express4';
|
||||
import { expressMiddleware } from '@as-integrations/express5';
|
||||
import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled';
|
||||
import express from 'express';
|
||||
import { execute, subscribe, GraphQLError } from 'graphql';
|
||||
import { execute, subscribe, GraphQLError, parse } from 'graphql';
|
||||
import { SubscriptionServer } from 'subscriptions-transport-ws';
|
||||
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
|
||||
import requiredParameter from '../requiredParameter';
|
||||
@@ -13,6 +13,42 @@ import { ParseGraphQLSchema } from './ParseGraphQLSchema';
|
||||
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
|
||||
|
||||
|
||||
const hasTypeIntrospection = (query) => {
|
||||
try {
|
||||
const ast = parse(query);
|
||||
// Check only root-level fields in the query
|
||||
// Note: selection.name.value is the actual field name, so this correctly handles
|
||||
// aliases like "myAlias: __type(...)" where name.value === "__type"
|
||||
for (const definition of ast.definitions) {
|
||||
if ((definition.kind === 'OperationDefinition' || definition.kind === 'FragmentDefinition') && definition.selectionSet) {
|
||||
for (const selection of definition.selectionSet.selections) {
|
||||
if (selection.kind === 'Field' && selection.name.value === '__type') {
|
||||
// GraphQL's introspection __type field requires a 'name' argument
|
||||
// This distinguishes it from potential user-defined __type fields
|
||||
if (selection.arguments && selection.arguments.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
// If parsing fails, we assume it's not a valid query and let Apollo handle it
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const throwIntrospectionError = () => {
|
||||
throw new GraphQLError('Introspection is not allowed', {
|
||||
extensions: {
|
||||
http: {
|
||||
status: 403,
|
||||
},
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const IntrospectionControlPlugin = (publicIntrospection) => ({
|
||||
|
||||
|
||||
@@ -29,21 +65,20 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Now we check if the query is an introspection query
|
||||
// this check strategy should work in 99.99% cases
|
||||
// we can have an issue if a user name a field or class __schemaSomething
|
||||
// we want to avoid a full AST check
|
||||
const isIntrospectionQuery =
|
||||
requestContext.request.query?.includes('__schema')
|
||||
const query = requestContext.request.query;
|
||||
|
||||
if (isIntrospectionQuery) {
|
||||
throw new GraphQLError('Introspection is not allowed', {
|
||||
extensions: {
|
||||
http: {
|
||||
status: 403,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// Fast path: simple string check for __schema
|
||||
// This avoids parsing the query in most cases
|
||||
if (query?.includes('__schema')) {
|
||||
return throwIntrospectionError();
|
||||
}
|
||||
|
||||
// Smart check for __type: only parse if the string is present
|
||||
// This avoids false positives (e.g., "__type" in strings or comments)
|
||||
// while still being efficient for the common case
|
||||
if (query?.includes('__type') && hasTypeIntrospection(query)) {
|
||||
return throwIntrospectionError();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user