Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions spec/ParseGraphQLServer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,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');
}
});
});


Expand Down
40 changes: 28 additions & 12 deletions src/GraphQL/ParseGraphQLServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,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;
}
Expand All @@ -108,11 +116,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);
}
});
},
}),
});
Expand Down
Loading