Skip to content

fix: Data schema exposed via GraphQL API public introspection (GHSA-48q3-prgv-gm4w) #9819

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 10, 2025
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
137 changes: 122 additions & 15 deletions spec/ParseGraphQLServer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ describe('ParseGraphQLServer', () => {

beforeEach(async () => {
parseServer = await global.reconfigureServer({
maintenanceKey: 'test2',
maxUploadSize: '1kb',
});
parseGraphQLServer = new ParseGraphQLServer(parseServer, {
Expand Down Expand Up @@ -88,8 +89,8 @@ describe('ParseGraphQLServer', () => {

it('should initialize parseGraphQLSchema with a log controller', async () => {
const loggerAdapter = {
log: () => {},
error: () => {},
log: () => { },
error: () => { },
};
const parseServer = await global.reconfigureServer({
loggerAdapter,
Expand Down Expand Up @@ -124,10 +125,10 @@ describe('ParseGraphQLServer', () => {
info: new Object(),
config: new Object(),
auth: new Object(),
get: () => {},
get: () => { },
};
const res = {
set: () => {},
set: () => { },
};

it_id('0696675e-060f-414f-bc77-9d57f31807f5')(it)('should return schema and context with req\'s info, config and auth', async () => {
Expand Down Expand Up @@ -431,7 +432,7 @@ describe('ParseGraphQLServer', () => {
objects.push(object1, object2, object3, object4);
}

async function createGQLFromParseServer(_parseServer) {
async function createGQLFromParseServer(_parseServer, parseGraphQLServerOptions) {
if (parseLiveQueryServer) {
await parseLiveQueryServer.server.close();
}
Expand All @@ -448,6 +449,7 @@ describe('ParseGraphQLServer', () => {
graphQLPath: '/graphql',
playgroundPath: '/playground',
subscriptionsPath: '/subscriptions',
...parseGraphQLServerOptions,
});
parseGraphQLServer.applyGraphQL(expressApp);
parseGraphQLServer.applyPlayground(expressApp);
Expand Down Expand Up @@ -488,8 +490,8 @@ describe('ParseGraphQLServer', () => {
},
},
});
spyOn(console, 'warn').and.callFake(() => {});
spyOn(console, 'error').and.callFake(() => {});
spyOn(console, 'warn').and.callFake(() => { });
spyOn(console, 'error').and.callFake(() => { });
});

afterEach(async () => {
Expand Down Expand Up @@ -605,6 +607,96 @@ describe('ParseGraphQLServer', () => {
]);
};

describe('Introspection', () => {
it('should have public introspection disabled by default without master key', async () => {

try {
await apolloClient.query({
query: gql`
query Introspection {
__schema {
types {
name
}
}
}
`,
})

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 always work with master key', async () => {
const introspection =
await apolloClient.query({
query: gql`
query Introspection {
__schema {
types {
name
}
}
}
`,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
}
},)
expect(introspection.data).toBeDefined();
expect(introspection.errors).not.toBeDefined();
});

it('should always work with maintenance key', async () => {
const introspection =
await apolloClient.query({
query: gql`
query Introspection {
__schema {
types {
name
}
}
}
`,
context: {
headers: {
'X-Parse-Maintenance-Key': 'test2',
},
}
},)
expect(introspection.data).toBeDefined();
expect(introspection.errors).not.toBeDefined();
});

it('should have public introspection enabled if enabled', async () => {

const parseServer = await reconfigureServer();
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });

const introspection =
await apolloClient.query({
query: gql`
query Introspection {
__schema {
types {
name
}
}
}
`,
})
expect(introspection.data).toBeDefined();
});
});


describe('Default Types', () => {
it('should have Object scalar type', async () => {
const objectType = (
Expand Down Expand Up @@ -749,6 +841,11 @@ describe('ParseGraphQLServer', () => {
}
}
`,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
}
})
).data['__schema'].types.map(type => type.name);

Expand Down Expand Up @@ -780,6 +877,11 @@ describe('ParseGraphQLServer', () => {
}
}
`,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
}
})
).data['__schema'].types.map(type => type.name);

Expand Down Expand Up @@ -864,7 +966,7 @@ describe('ParseGraphQLServer', () => {
});

it('should have clientMutationId in call function input', async () => {
Parse.Cloud.define('hello', () => {});
Parse.Cloud.define('hello', () => { });

const callFunctionInputFields = (
await apolloClient.query({
Expand All @@ -886,7 +988,7 @@ describe('ParseGraphQLServer', () => {
});

it('should have clientMutationId in call function payload', async () => {
Parse.Cloud.define('hello', () => {});
Parse.Cloud.define('hello', () => { });

const callFunctionPayloadFields = (
await apolloClient.query({
Expand Down Expand Up @@ -1312,6 +1414,11 @@ describe('ParseGraphQLServer', () => {
}
}
`,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
}
})
).data['__schema'].types.map(type => type.name);

Expand Down Expand Up @@ -7447,9 +7554,9 @@ describe('ParseGraphQLServer', () => {
it('should send reset password', async () => {
const clientMutationId = uuidv4();
const emailAdapter = {
sendVerificationEmail: () => {},
sendVerificationEmail: () => { },
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {},
sendMail: () => { },
};
parseServer = await global.reconfigureServer({
appName: 'test',
Expand Down Expand Up @@ -7488,11 +7595,11 @@ describe('ParseGraphQLServer', () => {
const clientMutationId = uuidv4();
let resetPasswordToken;
const emailAdapter = {
sendVerificationEmail: () => {},
sendVerificationEmail: () => { },
sendPasswordResetEmail: ({ link }) => {
resetPasswordToken = link.split('token=')[1].split('&')[0];
},
sendMail: () => {},
sendMail: () => { },
};
parseServer = await global.reconfigureServer({
appName: 'test',
Expand Down Expand Up @@ -7558,9 +7665,9 @@ describe('ParseGraphQLServer', () => {
it('should send verification email again', async () => {
const clientMutationId = uuidv4();
const emailAdapter = {
sendVerificationEmail: () => {},
sendVerificationEmail: () => { },
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {},
sendMail: () => { },
};
parseServer = await global.reconfigureServer({
appName: 'test',
Expand Down
4 changes: 4 additions & 0 deletions spec/SecurityCheckGroups.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('Security Check Groups', () => {
config.security.enableCheckLog = false;
config.allowClientClassCreation = false;
config.enableInsecureAuthAdapters = false;
config.graphQLPublicIntrospection = false;
await reconfigureServer(config);

const group = new CheckGroupServerConfig();
Expand All @@ -41,12 +42,14 @@ describe('Security Check Groups', () => {
expect(group.checks()[1].checkState()).toBe(CheckState.success);
expect(group.checks()[2].checkState()).toBe(CheckState.success);
expect(group.checks()[4].checkState()).toBe(CheckState.success);
expect(group.checks()[5].checkState()).toBe(CheckState.success);
});

it('checks fail correctly', async () => {
config.masterKey = 'insecure';
config.security.enableCheckLog = true;
config.allowClientClassCreation = true;
config.graphQLPublicIntrospection = true;
await reconfigureServer(config);

const group = new CheckGroupServerConfig();
Expand All @@ -55,6 +58,7 @@ describe('Security Check Groups', () => {
expect(group.checks()[1].checkState()).toBe(CheckState.fail);
expect(group.checks()[2].checkState()).toBe(CheckState.fail);
expect(group.checks()[4].checkState()).toBe(CheckState.fail);
expect(group.checks()[5].checkState()).toBe(CheckState.fail);
});
});

Expand Down
47 changes: 43 additions & 4 deletions src/GraphQL/ParseGraphQLServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,53 @@ import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled';
import express from 'express';
import { execute, subscribe } from 'graphql';
import { execute, subscribe, GraphQLError } from 'graphql';
import { SubscriptionServer } from 'subscriptions-transport-ws';
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
import requiredParameter from '../requiredParameter';
import defaultLogger from '../logger';
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';


const IntrospectionControlPlugin = (publicIntrospection) => ({


requestDidStart: (requestContext) => ({

didResolveOperation: async () => {
// If public introspection is enabled, we allow all introspection queries
if (publicIntrospection) {
return;
}

const isMasterOrMaintenance = requestContext.contextValue.auth?.isMaster || requestContext.contextValue.auth?.isMaintenance
if (isMasterOrMaintenance) {
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')

if (isIntrospectionQuery) {
throw new GraphQLError('Introspection is not allowed', {
extensions: {
http: {
status: 403,
},
}
});
}
},

})

});

class ParseGraphQLServer {
parseGraphQLController: ParseGraphQLController;

Expand Down Expand Up @@ -65,8 +104,8 @@ class ParseGraphQLServer {
// needed since we use graphql upload
requestHeaders: ['X-Parse-Application-Id'],
},
introspection: true,
plugins: [ApolloServerPluginCacheControlDisabled()],
introspection: this.config.graphQLPublicIntrospection,
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)],
schema,
});
await apollo.start();
Expand Down Expand Up @@ -118,7 +157,7 @@ class ParseGraphQLServer {

app.get(
this.config.playgroundPath ||
requiredParameter('You must provide a config.playgroundPath to applyPlayground!'),
requiredParameter('You must provide a config.playgroundPath to applyPlayground!'),
(_req, res) => {
res.setHeader('Content-Type', 'text/html');
res.write(
Expand Down
6 changes: 6 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,12 @@ module.exports.ParseServerOptions = {
help: 'Mount path for the GraphQL endpoint, defaults to /graphql',
default: '/graphql',
},
graphQLPublicIntrospection: {
env: 'PARSE_SERVER_GRAPHQL_PUBLIC_INTROSPECTION',
help: 'Enable public introspection for the GraphQL endpoint, defaults to false',
action: parsers.booleanParser,
default: false,
},
graphQLSchema: {
env: 'PARSE_SERVER_GRAPH_QLSCHEMA',
help: 'Full path to your GraphQL custom schema.graphql file',
Expand Down
1 change: 1 addition & 0 deletions src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ export interface ParseServerOptions {
:ENV: PARSE_SERVER_GRAPHQL_PATH
:DEFAULT: /graphql */
graphQLPath: ?string;
/* Enable public introspection for the GraphQL endpoint, defaults to false
:ENV: PARSE_SERVER_GRAPHQL_PUBLIC_INTROSPECTION
:DEFAULT: false */
graphQLPublicIntrospection: ?boolean;
/* Mounts the GraphQL Playground - never use this option in production
:ENV: PARSE_SERVER_MOUNT_PLAYGROUND
:DEFAULT: false */
Expand Down
Loading
Loading