Skip to content

Commit c10f4ac

Browse files
authored
fix: Data schema exposed via GraphQL API public introspection (GHSA-48q3-prgv-gm4w) (#9820)
1 parent 0f2aa28 commit c10f4ac

File tree

8 files changed

+208
-22
lines changed

8 files changed

+208
-22
lines changed

spec/ParseGraphQLServer.spec.js

Lines changed: 137 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,17 +432,33 @@ describe('ParseGraphQLServer', () => {
431432
objects.push(object1, object2, object3, object4);
432433
}
433434

434-
beforeEach(async () => {
435+
async function createGQLFromParseServer(_parseServer, parseGraphQLServerOptions) {
436+
if (parseLiveQueryServer) {
437+
await parseLiveQueryServer.server.close();
438+
}
439+
if (httpServer) {
440+
await httpServer.close();
441+
}
435442
const expressApp = express();
436443
httpServer = http.createServer(expressApp);
437444
expressApp.use('/parse', parseServer.app);
438445
parseLiveQueryServer = await ParseServer.createLiveQueryServer(httpServer, {
439446
port: 1338,
440447
});
448+
parseGraphQLServer = new ParseGraphQLServer(_parseServer, {
449+
graphQLPath: '/graphql',
450+
playgroundPath: '/playground',
451+
subscriptionsPath: '/subscriptions',
452+
...parseGraphQLServerOptions,
453+
});
441454
parseGraphQLServer.applyGraphQL(expressApp);
442455
parseGraphQLServer.applyPlayground(expressApp);
443456
parseGraphQLServer.createSubscriptions(httpServer);
444457
await new Promise(resolve => httpServer.listen({ port: 13377 }, resolve));
458+
}
459+
460+
beforeEach(async () => {
461+
await createGQLFromParseServer(parseServer);
445462

446463
const subscriptionClient = new SubscriptionClient(
447464
'ws://localhost:13377/subscriptions',
@@ -473,8 +490,8 @@ describe('ParseGraphQLServer', () => {
473490
},
474491
},
475492
});
476-
spyOn(console, 'warn').and.callFake(() => {});
477-
spyOn(console, 'error').and.callFake(() => {});
493+
spyOn(console, 'warn').and.callFake(() => { });
494+
spyOn(console, 'error').and.callFake(() => { });
478495
});
479496

480497
afterEach(async () => {
@@ -590,6 +607,96 @@ describe('ParseGraphQLServer', () => {
590607
]);
591608
};
592609

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+
593700
describe('Default Types', () => {
594701
it('should have Object scalar type', async () => {
595702
const objectType = (
@@ -734,6 +841,11 @@ describe('ParseGraphQLServer', () => {
734841
}
735842
}
736843
`,
844+
context: {
845+
headers: {
846+
'X-Parse-Master-Key': 'test',
847+
},
848+
}
737849
})
738850
).data['__schema'].types.map(type => type.name);
739851

@@ -769,6 +881,11 @@ describe('ParseGraphQLServer', () => {
769881
}
770882
}
771883
`,
884+
context: {
885+
headers: {
886+
'X-Parse-Master-Key': 'test',
887+
},
888+
}
772889
})
773890
).data['__schema'].types.map(type => type.name);
774891

@@ -853,7 +970,7 @@ describe('ParseGraphQLServer', () => {
853970
});
854971

855972
it('should have clientMutationId in call function input', async () => {
856-
Parse.Cloud.define('hello', () => {});
973+
Parse.Cloud.define('hello', () => { });
857974

858975
const callFunctionInputFields = (
859976
await apolloClient.query({
@@ -875,7 +992,7 @@ describe('ParseGraphQLServer', () => {
875992
});
876993

877994
it('should have clientMutationId in call function payload', async () => {
878-
Parse.Cloud.define('hello', () => {});
995+
Parse.Cloud.define('hello', () => { });
879996

880997
const callFunctionPayloadFields = (
881998
await apolloClient.query({
@@ -1301,6 +1418,11 @@ describe('ParseGraphQLServer', () => {
13011418
}
13021419
}
13031420
`,
1421+
context: {
1422+
headers: {
1423+
'X-Parse-Master-Key': 'test',
1424+
},
1425+
}
13041426
})
13051427
).data['__schema'].types.map(type => type.name);
13061428

@@ -7432,9 +7554,9 @@ describe('ParseGraphQLServer', () => {
74327554
it('should send reset password', async () => {
74337555
const clientMutationId = uuidv4();
74347556
const emailAdapter = {
7435-
sendVerificationEmail: () => {},
7557+
sendVerificationEmail: () => { },
74367558
sendPasswordResetEmail: () => Promise.resolve(),
7437-
sendMail: () => {},
7559+
sendMail: () => { },
74387560
};
74397561
parseServer = await global.reconfigureServer({
74407562
appName: 'test',
@@ -7472,11 +7594,11 @@ describe('ParseGraphQLServer', () => {
74727594
const clientMutationId = uuidv4();
74737595
let resetPasswordToken;
74747596
const emailAdapter = {
7475-
sendVerificationEmail: () => {},
7597+
sendVerificationEmail: () => { },
74767598
sendPasswordResetEmail: ({ link }) => {
74777599
resetPasswordToken = link.split('token=')[1].split('&')[0];
74787600
},
7479-
sendMail: () => {},
7601+
sendMail: () => { },
74807602
};
74817603
parseServer = await global.reconfigureServer({
74827604
appName: 'test',
@@ -7541,9 +7663,9 @@ describe('ParseGraphQLServer', () => {
75417663
it('should send verification email again', async () => {
75427664
const clientMutationId = uuidv4();
75437665
const emailAdapter = {
7544-
sendVerificationEmail: () => {},
7666+
sendVerificationEmail: () => { },
75457667
sendPasswordResetEmail: () => Promise.resolve(),
7546-
sendMail: () => {},
7668+
sendMail: () => { },
75477669
};
75487670
parseServer = await global.reconfigureServer({
75497671
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.

0 commit comments

Comments
 (0)