Skip to content

Commit c58b2eb

Browse files
authored
fix: Data schema exposed via GraphQL API public introspection (GHSA-48q3-prgv-gm4w) (#9819)
1 parent 2c29756 commit c58b2eb

File tree

8 files changed

+193
-22
lines changed

8 files changed

+193
-22
lines changed

spec/ParseGraphQLServer.spec.js

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ describe('ParseGraphQLServer', () => {
5050

5151
beforeEach(async () => {
5252
parseServer = await global.reconfigureServer({
53+
maintenanceKey: 'test2',
5354
maxUploadSize: '1kb',
5455
});
5556
parseGraphQLServer = new ParseGraphQLServer(parseServer, {
@@ -88,8 +89,8 @@ describe('ParseGraphQLServer', () => {
8889

8990
it('should initialize parseGraphQLSchema with a log controller', async () => {
9091
const loggerAdapter = {
91-
log: () => {},
92-
error: () => {},
92+
log: () => { },
93+
error: () => { },
9394
};
9495
const parseServer = await global.reconfigureServer({
9596
loggerAdapter,
@@ -124,10 +125,10 @@ describe('ParseGraphQLServer', () => {
124125
info: new Object(),
125126
config: new Object(),
126127
auth: new Object(),
127-
get: () => {},
128+
get: () => { },
128129
};
129130
const res = {
130-
set: () => {},
131+
set: () => { },
131132
};
132133

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

434-
async function createGQLFromParseServer(_parseServer) {
435+
async function createGQLFromParseServer(_parseServer, parseGraphQLServerOptions) {
435436
if (parseLiveQueryServer) {
436437
await parseLiveQueryServer.server.close();
437438
}
@@ -448,6 +449,7 @@ describe('ParseGraphQLServer', () => {
448449
graphQLPath: '/graphql',
449450
playgroundPath: '/playground',
450451
subscriptionsPath: '/subscriptions',
452+
...parseGraphQLServerOptions,
451453
});
452454
parseGraphQLServer.applyGraphQL(expressApp);
453455
parseGraphQLServer.applyPlayground(expressApp);
@@ -488,8 +490,8 @@ describe('ParseGraphQLServer', () => {
488490
},
489491
},
490492
});
491-
spyOn(console, 'warn').and.callFake(() => {});
492-
spyOn(console, 'error').and.callFake(() => {});
493+
spyOn(console, 'warn').and.callFake(() => { });
494+
spyOn(console, 'error').and.callFake(() => { });
493495
});
494496

495497
afterEach(async () => {
@@ -605,6 +607,96 @@ describe('ParseGraphQLServer', () => {
605607
]);
606608
};
607609

610+
describe('Introspection', () => {
611+
it('should have public introspection disabled by default without master key', async () => {
612+
613+
try {
614+
await apolloClient.query({
615+
query: gql`
616+
query Introspection {
617+
__schema {
618+
types {
619+
name
620+
}
621+
}
622+
}
623+
`,
624+
})
625+
626+
fail('should have thrown an error');
627+
628+
} catch (e) {
629+
expect(e.message).toEqual('Response not successful: Received status code 403');
630+
expect(e.networkError.result.errors[0].message).toEqual('Introspection is not allowed');
631+
}
632+
});
633+
634+
it('should always work with master key', async () => {
635+
const introspection =
636+
await apolloClient.query({
637+
query: gql`
638+
query Introspection {
639+
__schema {
640+
types {
641+
name
642+
}
643+
}
644+
}
645+
`,
646+
context: {
647+
headers: {
648+
'X-Parse-Master-Key': 'test',
649+
},
650+
}
651+
},)
652+
expect(introspection.data).toBeDefined();
653+
expect(introspection.errors).not.toBeDefined();
654+
});
655+
656+
it('should always work with maintenance key', async () => {
657+
const introspection =
658+
await apolloClient.query({
659+
query: gql`
660+
query Introspection {
661+
__schema {
662+
types {
663+
name
664+
}
665+
}
666+
}
667+
`,
668+
context: {
669+
headers: {
670+
'X-Parse-Maintenance-Key': 'test2',
671+
},
672+
}
673+
},)
674+
expect(introspection.data).toBeDefined();
675+
expect(introspection.errors).not.toBeDefined();
676+
});
677+
678+
it('should have public introspection enabled if enabled', async () => {
679+
680+
const parseServer = await reconfigureServer();
681+
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
682+
683+
const introspection =
684+
await apolloClient.query({
685+
query: gql`
686+
query Introspection {
687+
__schema {
688+
types {
689+
name
690+
}
691+
}
692+
}
693+
`,
694+
})
695+
expect(introspection.data).toBeDefined();
696+
});
697+
});
698+
699+
608700
describe('Default Types', () => {
609701
it('should have Object scalar type', async () => {
610702
const objectType = (
@@ -749,6 +841,11 @@ describe('ParseGraphQLServer', () => {
749841
}
750842
}
751843
`,
844+
context: {
845+
headers: {
846+
'X-Parse-Master-Key': 'test',
847+
},
848+
}
752849
})
753850
).data['__schema'].types.map(type => type.name);
754851

@@ -780,6 +877,11 @@ describe('ParseGraphQLServer', () => {
780877
}
781878
}
782879
`,
880+
context: {
881+
headers: {
882+
'X-Parse-Master-Key': 'test',
883+
},
884+
}
783885
})
784886
).data['__schema'].types.map(type => type.name);
785887

@@ -864,7 +966,7 @@ describe('ParseGraphQLServer', () => {
864966
});
865967

866968
it('should have clientMutationId in call function input', async () => {
867-
Parse.Cloud.define('hello', () => {});
969+
Parse.Cloud.define('hello', () => { });
868970

869971
const callFunctionInputFields = (
870972
await apolloClient.query({
@@ -886,7 +988,7 @@ describe('ParseGraphQLServer', () => {
886988
});
887989

888990
it('should have clientMutationId in call function payload', async () => {
889-
Parse.Cloud.define('hello', () => {});
991+
Parse.Cloud.define('hello', () => { });
890992

891993
const callFunctionPayloadFields = (
892994
await apolloClient.query({
@@ -1312,6 +1414,11 @@ describe('ParseGraphQLServer', () => {
13121414
}
13131415
}
13141416
`,
1417+
context: {
1418+
headers: {
1419+
'X-Parse-Master-Key': 'test',
1420+
},
1421+
}
13151422
})
13161423
).data['__schema'].types.map(type => type.name);
13171424

@@ -7447,9 +7554,9 @@ describe('ParseGraphQLServer', () => {
74477554
it('should send reset password', async () => {
74487555
const clientMutationId = uuidv4();
74497556
const emailAdapter = {
7450-
sendVerificationEmail: () => {},
7557+
sendVerificationEmail: () => { },
74517558
sendPasswordResetEmail: () => Promise.resolve(),
7452-
sendMail: () => {},
7559+
sendMail: () => { },
74537560
};
74547561
parseServer = await global.reconfigureServer({
74557562
appName: 'test',
@@ -7488,11 +7595,11 @@ describe('ParseGraphQLServer', () => {
74887595
const clientMutationId = uuidv4();
74897596
let resetPasswordToken;
74907597
const emailAdapter = {
7491-
sendVerificationEmail: () => {},
7598+
sendVerificationEmail: () => { },
74927599
sendPasswordResetEmail: ({ link }) => {
74937600
resetPasswordToken = link.split('token=')[1].split('&')[0];
74947601
},
7495-
sendMail: () => {},
7602+
sendMail: () => { },
74967603
};
74977604
parseServer = await global.reconfigureServer({
74987605
appName: 'test',
@@ -7558,9 +7665,9 @@ describe('ParseGraphQLServer', () => {
75587665
it('should send verification email again', async () => {
75597666
const clientMutationId = uuidv4();
75607667
const emailAdapter = {
7561-
sendVerificationEmail: () => {},
7668+
sendVerificationEmail: () => { },
75627669
sendPasswordResetEmail: () => Promise.resolve(),
7563-
sendMail: () => {},
7670+
sendMail: () => { },
75647671
};
75657672
parseServer = await global.reconfigureServer({
75667673
appName: 'test',

spec/SecurityCheckGroups.spec.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('Security Check Groups', () => {
3333
config.security.enableCheckLog = false;
3434
config.allowClientClassCreation = false;
3535
config.enableInsecureAuthAdapters = false;
36+
config.graphQLPublicIntrospection = false;
3637
await reconfigureServer(config);
3738

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

4648
it('checks fail correctly', async () => {
4749
config.masterKey = 'insecure';
4850
config.security.enableCheckLog = true;
4951
config.allowClientClassCreation = true;
52+
config.graphQLPublicIntrospection = true;
5053
await reconfigureServer(config);
5154

5255
const group = new CheckGroupServerConfig();
@@ -55,6 +58,7 @@ describe('Security Check Groups', () => {
5558
expect(group.checks()[1].checkState()).toBe(CheckState.fail);
5659
expect(group.checks()[2].checkState()).toBe(CheckState.fail);
5760
expect(group.checks()[4].checkState()).toBe(CheckState.fail);
61+
expect(group.checks()[5].checkState()).toBe(CheckState.fail);
5862
});
5963
});
6064

src/GraphQL/ParseGraphQLServer.js

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,53 @@ import { ApolloServer } from '@apollo/server';
44
import { expressMiddleware } from '@apollo/server/express4';
55
import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled';
66
import express from 'express';
7-
import { execute, subscribe } from 'graphql';
7+
import { execute, subscribe, GraphQLError } from 'graphql';
88
import { SubscriptionServer } from 'subscriptions-transport-ws';
99
import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
1010
import requiredParameter from '../requiredParameter';
1111
import defaultLogger from '../logger';
1212
import { ParseGraphQLSchema } from './ParseGraphQLSchema';
1313
import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController';
1414

15+
16+
const IntrospectionControlPlugin = (publicIntrospection) => ({
17+
18+
19+
requestDidStart: (requestContext) => ({
20+
21+
didResolveOperation: async () => {
22+
// If public introspection is enabled, we allow all introspection queries
23+
if (publicIntrospection) {
24+
return;
25+
}
26+
27+
const isMasterOrMaintenance = requestContext.contextValue.auth?.isMaster || requestContext.contextValue.auth?.isMaintenance
28+
if (isMasterOrMaintenance) {
29+
return;
30+
}
31+
32+
// Now we check if the query is an introspection query
33+
// this check strategy should work in 99.99% cases
34+
// we can have an issue if a user name a field or class __schemaSomething
35+
// we want to avoid a full AST check
36+
const isIntrospectionQuery =
37+
requestContext.request.query?.includes('__schema')
38+
39+
if (isIntrospectionQuery) {
40+
throw new GraphQLError('Introspection is not allowed', {
41+
extensions: {
42+
http: {
43+
status: 403,
44+
},
45+
}
46+
});
47+
}
48+
},
49+
50+
})
51+
52+
});
53+
1554
class ParseGraphQLServer {
1655
parseGraphQLController: ParseGraphQLController;
1756

@@ -65,8 +104,8 @@ class ParseGraphQLServer {
65104
// needed since we use graphql upload
66105
requestHeaders: ['X-Parse-Application-Id'],
67106
},
68-
introspection: true,
69-
plugins: [ApolloServerPluginCacheControlDisabled()],
107+
introspection: this.config.graphQLPublicIntrospection,
108+
plugins: [ApolloServerPluginCacheControlDisabled(), IntrospectionControlPlugin(this.config.graphQLPublicIntrospection)],
70109
schema,
71110
});
72111
await apollo.start();
@@ -118,7 +157,7 @@ class ParseGraphQLServer {
118157

119158
app.get(
120159
this.config.playgroundPath ||
121-
requiredParameter('You must provide a config.playgroundPath to applyPlayground!'),
160+
requiredParameter('You must provide a config.playgroundPath to applyPlayground!'),
122161
(_req, res) => {
123162
res.setHeader('Content-Type', 'text/html');
124163
res.write(

src/Options/Definitions.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,12 @@ module.exports.ParseServerOptions = {
292292
help: 'Mount path for the GraphQL endpoint, defaults to /graphql',
293293
default: '/graphql',
294294
},
295+
graphQLPublicIntrospection: {
296+
env: 'PARSE_SERVER_GRAPHQL_PUBLIC_INTROSPECTION',
297+
help: 'Enable public introspection for the GraphQL endpoint, defaults to false',
298+
action: parsers.booleanParser,
299+
default: false,
300+
},
295301
graphQLSchema: {
296302
env: 'PARSE_SERVER_GRAPH_QLSCHEMA',
297303
help: 'Full path to your GraphQL custom schema.graphql file',

src/Options/docs.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Options/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,10 @@ export interface ParseServerOptions {
306306
:ENV: PARSE_SERVER_GRAPHQL_PATH
307307
:DEFAULT: /graphql */
308308
graphQLPath: ?string;
309+
/* Enable public introspection for the GraphQL endpoint, defaults to false
310+
:ENV: PARSE_SERVER_GRAPHQL_PUBLIC_INTROSPECTION
311+
:DEFAULT: false */
312+
graphQLPublicIntrospection: ?boolean;
309313
/* Mounts the GraphQL Playground - never use this option in production
310314
:ENV: PARSE_SERVER_MOUNT_PLAYGROUND
311315
:DEFAULT: false */

0 commit comments

Comments
 (0)