fix: GraphQL variable-coercion suggestions disclose schema to unauthenticated callers ([GHSA-9g8f-h8f3-hjcm](https://github.com/parse-community/parse-server/security/advisories/GHSA-9g8f-h8f3-hjcm)) (#10564)

This commit is contained in:
Manuel
2026-07-08 06:49:11 +02:00
committed by GitHub
parent e66482b00e
commit 2728fcbabc
2 changed files with 134 additions and 12 deletions
+106
View File
@@ -824,6 +824,112 @@ describe('ParseGraphQLServer', () => {
expect(message).toContain('health');
}
});
const getReturnedError = e =>
(e.networkError && e.networkError.result && e.networkError.result.errors[0]) ||
(e.graphQLErrors && e.graphQLErrors[0]);
it('should strip "Did you mean" enum suggestions from variable-coercion errors without master or maintenance key', async () => {
Parse.Cloud.define('secretAdminTask', () => 'ok');
try {
await apolloClient.mutate({
mutation: gql`
mutation LeakFunction($input: CallCloudCodeInput!) {
callCloudCode(input: $input) {
result
}
}
`,
variables: { input: { functionName: 'secretAdminTas', params: {} } },
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toContain('CloudCodeFunction');
expect(error.message).not.toMatch(/Did you mean/);
expect(error.message).not.toContain('secretAdminTask');
// The cloud function name must not leak through any returned field
// (e.g. a stacktrace duplicated from the original message in non-production).
expect(JSON.stringify(error)).not.toContain('secretAdminTask');
}
});
it('should strip "Did you mean" field suggestions from variable-coercion errors without master or maintenance key', async () => {
try {
await apolloClient.query({
query: gql`
query Leak($where: UserWhereInput) {
users(where: $where) {
edges {
node {
id
}
}
}
}
`,
variables: { where: { usernme: { equalTo: 'victim' } } },
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toContain('UserWhereInput');
expect(error.message).not.toMatch(/Did you mean/);
// JSON.stringify escapes embedded quotes, so assert against the bare
// identifier to reliably catch a leak duplicated into extensions.stacktrace.
expect(error.message).not.toContain('username');
expect(JSON.stringify(error)).not.toContain('username');
}
});
it('should keep "Did you mean" enum suggestions in variable-coercion errors with master key', async () => {
Parse.Cloud.define('secretAdminTask', () => 'ok');
try {
await apolloClient.mutate({
mutation: gql`
mutation LeakFunction($input: CallCloudCodeInput!) {
callCloudCode(input: $input) {
result
}
}
`,
variables: { input: { functionName: 'secretAdminTas', params: {} } },
context: {
headers: {
'X-Parse-Master-Key': 'test',
},
},
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toMatch(/Did you mean/);
expect(error.message).toContain('secretAdminTask');
}
});
it('should keep "Did you mean" enum suggestions in variable-coercion errors when public introspection is enabled', async () => {
const parseServer = await reconfigureServer();
await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true });
Parse.Cloud.define('secretAdminTask', () => 'ok');
try {
await apolloClient.mutate({
mutation: gql`
mutation LeakFunction($input: CallCloudCodeInput!) {
callCloudCode(input: $input) {
result
}
}
`,
variables: { input: { functionName: 'secretAdminTas', params: {} } },
});
fail('should have thrown a coercion error');
} catch (e) {
const error = getReturnedError(e);
expect(error.message).toMatch(/Did you mean/);
expect(error.message).toContain('secretAdminTask');
}
});
});
+28 -12
View File
@@ -50,15 +50,23 @@ const IntrospectionControlPlugin = (publicIntrospection) => ({
});
// graphql-js validation rules (FieldsOnCorrectTypeRule, KnownArgumentNamesRule,
// KnownTypeNamesRule, ...) embed "Did you mean ...?" hints sourced from the live
// schema in their error messages. Those messages are returned to the caller
// before didResolveOperation runs, so they sidestep IntrospectionControlPlugin
// and disclose schema identifiers the introspection guard is meant to hide.
// Strip the hint suffix for callers that are not allowed to introspect.
// graphql-js embeds "Did you mean ...?" hints sourced from the live schema in
// its error messages. They are produced in two distinct phases:
// - validation rules (FieldsOnCorrectTypeRule, KnownArgumentNamesRule,
// KnownTypeNamesRule, ...), and
// - variable coercion (unknown enum values, unknown input-object fields),
// which runs during execution, after validation.
// All of these are returned to the caller and disclose schema identifiers (Cloud
// Code function names, class and field names) that the introspection guard is
// meant to hide. Strip the hint suffix from every returned error — including the
// copy graphql-js duplicates into extensions.stacktrace in non-production — for
// callers that are not allowed to introspect.
const stripSchemaSuggestion = message =>
typeof message === 'string' ? message.replace(/ ?Did you mean(.+?)\?$/, '') : message;
const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({
requestDidStart: async (requestContext) => ({
validationDidStart: async () => {
willSendResponse: async () => {
if (publicIntrospection) {
return;
}
@@ -68,11 +76,19 @@ const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({
if (isMasterOrMaintenance) {
return;
}
return async (validationErrors) => {
validationErrors?.forEach(error => {
error.message = error.message.replace(/ ?Did you mean(.+?)\?$/, '');
});
};
const body = requestContext.response?.body;
const errors =
body?.kind === 'single'
? body.singleResult.errors
: body?.kind === 'incremental'
? body.initialResult.errors
: undefined;
errors?.forEach(error => {
error.message = stripSchemaSuggestion(error.message);
if (Array.isArray(error.extensions?.stacktrace)) {
error.extensions.stacktrace = error.extensions.stacktrace.map(stripSchemaSuggestion);
}
});
},
}),
});