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

This commit is contained in:
Manuel
2025-07-10 04:25:09 +02:00
committed by GitHub
parent 2c29756038
commit c58b2eb6eb
8 changed files with 193 additions and 22 deletions
+122 -15
View File
@@ -50,6 +50,7 @@ describe('ParseGraphQLServer', () => {
beforeEach(async () => {
parseServer = await global.reconfigureServer({
maintenanceKey: 'test2',
maxUploadSize: '1kb',
});
parseGraphQLServer = new ParseGraphQLServer(parseServer, {
@@ -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,
@@ -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 () => {
@@ -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();
}
@@ -448,6 +449,7 @@ describe('ParseGraphQLServer', () => {
graphQLPath: '/graphql',
playgroundPath: '/playground',
subscriptionsPath: '/subscriptions',
...parseGraphQLServerOptions,
});
parseGraphQLServer.applyGraphQL(expressApp);
parseGraphQLServer.applyPlayground(expressApp);
@@ -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 () => {
@@ -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 = (
@@ -749,6 +841,11 @@ describe('ParseGraphQLServer', () => {
}
}
`,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
}
})
).data['__schema'].types.map(type => type.name);
@@ -780,6 +877,11 @@ describe('ParseGraphQLServer', () => {
}
}
`,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
}
})
).data['__schema'].types.map(type => type.name);
@@ -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({
@@ -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({
@@ -1312,6 +1414,11 @@ describe('ParseGraphQLServer', () => {
}
}
`,
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
}
})
).data['__schema'].types.map(type => type.name);
@@ -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',
@@ -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',
@@ -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',
+4
View File
@@ -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();
@@ -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();
@@ -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);
});
});
+43 -4
View File
@@ -4,7 +4,7 @@ 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';
@@ -12,6 +12,45 @@ 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;
@@ -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();
@@ -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(
+6
View File
@@ -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',
+1
View File
@@ -53,6 +53,7 @@
* @property {Adapter<FilesAdapter>} filesAdapter Adapter module for the files sub-system
* @property {FileUploadOptions} fileUpload Options for file uploads
* @property {String} graphQLPath Mount path for the GraphQL endpoint, defaults to /graphql
* @property {Boolean} graphQLPublicIntrospection Enable public introspection for the GraphQL endpoint, defaults to false
* @property {String} graphQLSchema Full path to your GraphQL custom schema.graphql file
* @property {String} host The host to serve ParseServer on, defaults to 0.0.0.0
* @property {IdempotencyOptions} idempotencyOptions Options for request idempotency to deduplicate identical requests that may be caused by network issues. Caution, this is an experimental feature that may not be appropriate for production.
+4
View File
@@ -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 */
@@ -80,6 +80,16 @@ class CheckGroupServerConfig extends CheckGroup {
}
},
}),
new Check({
title: 'GraphQL public introspection disabled',
warning: 'GraphQL public introspection is enabled, which allows anyone to access the GraphQL schema.',
solution: "Change Parse Server configuration to 'graphQLPublicIntrospection: false'. You will need to use master key or maintenance key to access the GraphQL schema.",
check: () => {
if (config.graphQLPublicIntrospection !== false) {
throw 1;
}
},
}),
];
}
}
+3 -3
View File
@@ -539,9 +539,9 @@ export const addRateLimit = (route, config, cloud) => {
url: route.redisUrl,
});
client.on('error', err => { log.error('Middlewares addRateLimit Redis client error', { error: err }) });
client.on('connect', () => {});
client.on('reconnecting', () => {});
client.on('ready', () => {});
client.on('connect', () => { });
client.on('reconnecting', () => { });
client.on('ready', () => { });
redisStore.connectionPromise = async () => {
if (client.isOpen) {
return;