mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
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:
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user