diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 534fea9af9..df5da2070d 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -1357,6 +1357,377 @@ describe('ParseGraphQLServer', () => { expect(JSON.stringify(error)).not.toContain('secretRequiredField'); } }); + + // A Pointer/Relation field maps to a generated input type whose name embeds the + // pointer's TARGET class (`PointerInput`, `RelationWhereInput`, + // `CreateFieldsInput`). graphql-js interpolates that type name into base + // coercion/validation messages that the "Did you mean" and required-field strips do + // not touch, disclosing the target class name to a caller who only supplied the + // pointer field name (which does not reveal its target). Redact those identifiers + // for callers that are not allowed to introspect. + const setupPointerSchema = async _parseServer => { + const schemaController = await _parseServer.config.databaseController.loadSchema(); + await schemaController.addClassIfNotExists('SecretAuthor', { + name: { type: 'String' }, + }); + await schemaController.addClassIfNotExists('DiagBook', { + writtenBy: { type: 'Pointer', targetClass: 'SecretAuthor' }, + }); + await resetGraphQLCache(); + }; + + it('should strip pointer target class names from where-clause validation errors without master or maintenance key', async () => { + await setupPointerSchema(parseServer); + + try { + await apolloClient.query({ + query: gql` + query Leak { + diagBooks(where: { writtenBy: 123 }) { + edges { + node { + id + } + } + } + } + `, + }); + fail('should have thrown a validation error'); + } catch (e) { + const error = getReturnedError(e); + // Expected value of type "SecretAuthorRelationWhereInput", found 123. + expect(error.message).not.toContain('SecretAuthor'); + expect(JSON.stringify(error)).not.toContain('SecretAuthor'); + } + }); + + it('should strip pointer target class names from non-object variable-coercion errors without master or maintenance key', async () => { + await setupPointerSchema(parseServer); + + try { + await apolloClient.mutate({ + mutation: gql` + mutation Create($input: CreateDiagBookInput!) { + createDiagBook(input: $input) { + diagBook { + id + } + } + } + `, + variables: { input: { fields: { writtenBy: { createAndLink: 5 } } } }, + }); + fail('should have thrown a coercion error'); + } catch (e) { + const error = getReturnedError(e); + // Expected type "CreateSecretAuthorFieldsInput" to be an object. + expect(error.message).not.toContain('SecretAuthor'); + expect(JSON.stringify(error)).not.toContain('SecretAuthor'); + } + }); + + it('should strip pointer target class names from unknown-field variable-coercion errors without master or maintenance key', async () => { + await setupPointerSchema(parseServer); + + try { + await apolloClient.mutate({ + mutation: gql` + mutation Create($input: CreateDiagBookInput!) { + createDiagBook(input: $input) { + diagBook { + id + } + } + } + `, + variables: { input: { fields: { writtenBy: { bogusKey: 1 } } } }, + }); + fail('should have thrown a coercion error'); + } catch (e) { + const error = getReturnedError(e); + // Field "bogusKey" is not defined by type "SecretAuthorPointerInput". + expect(error.message).not.toContain('SecretAuthor'); + expect(JSON.stringify(error)).not.toContain('SecretAuthor'); + } + }); + + it('should keep pointer target class names in errors with master key', async () => { + await setupPointerSchema(parseServer); + + try { + await apolloClient.query({ + query: gql` + query Leak { + diagBooks(where: { writtenBy: 123 }) { + edges { + node { + id + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should have thrown a validation error'); + } catch (e) { + const error = getReturnedError(e); + expect(error.message).toContain('SecretAuthor'); + } + }); + + it('should keep pointer target class names in errors when public introspection is enabled', async () => { + const parseServer = await reconfigureServer(); + await createGQLFromParseServer(parseServer, { graphQLPublicIntrospection: true }); + await setupPointerSchema(parseServer); + + try { + await apolloClient.query({ + query: gql` + query Leak { + diagBooks(where: { writtenBy: 123 }) { + edges { + node { + id + } + } + } + } + `, + }); + fail('should have thrown a validation error'); + } catch (e) { + const error = getReturnedError(e); + expect(error.message).toContain('SecretAuthor'); + } + }); + + it('should strip pointer target class names from non-nullable variable-coercion errors without master or maintenance key', async () => { + const schemaController = await parseServer.config.databaseController.loadSchema(); + await schemaController.addClassIfNotExists('SecretAuthor', { name: { type: 'String' } }); + await schemaController.addClassIfNotExists('ReqDiagBook', { + writtenBy: { type: 'Pointer', targetClass: 'SecretAuthor', required: true }, + }); + await resetGraphQLCache(); + + try { + await apolloClient.mutate({ + mutation: gql` + mutation Create($input: CreateReqDiagBookInput!) { + createReqDiagBook(input: $input) { + reqDiagBook { + id + } + } + } + `, + variables: { input: { fields: { writtenBy: null } } }, + }); + fail('should have thrown a coercion error'); + } catch (e) { + const error = getReturnedError(e); + // Expected non-nullable type "SecretAuthorPointerInput!" not to be null. + expect(error.message).not.toContain('SecretAuthor'); + expect(JSON.stringify(error)).not.toContain('SecretAuthor'); + } + }); + + it('should strip pointer target class names from variable-position validation errors without master or maintenance key', async () => { + await setupPointerSchema(parseServer); + + try { + await apolloClient.query({ + query: gql` + query Leak($x: String) { + diagBooks(where: { writtenBy: $x }) { + edges { + node { + id + } + } + } + } + `, + variables: { x: 'anything' }, + }); + fail('should have thrown a validation error'); + } catch (e) { + const error = getReturnedError(e); + // Variable "$x" of type "String" used in position expecting type "SecretAuthorRelationWhereInput". + expect(error.message).not.toContain('SecretAuthor'); + expect(JSON.stringify(error)).not.toContain('SecretAuthor'); + } + }); + + it('should strip pointer target class names from mutation variable-position validation errors without master or maintenance key', async () => { + await setupPointerSchema(parseServer); + + try { + await apolloClient.mutate({ + mutation: gql` + mutation Create($x: String) { + createDiagBook(input: { fields: { writtenBy: { createAndLink: $x } } }) { + diagBook { + id + } + } + } + `, + variables: { x: 'anything' }, + }); + fail('should have thrown a validation error'); + } catch (e) { + const error = getReturnedError(e); + // Variable "$x" of type "String" used in position expecting type "CreateSecretAuthorFieldsInput". + expect(error.message).not.toContain('SecretAuthor'); + expect(JSON.stringify(error)).not.toContain('SecretAuthor'); + } + }); + + it('should strip pointer target class names from output-field validation errors without master or maintenance key', async () => { + await setupPointerSchema(parseServer); + + try { + await apolloClient.query({ + query: gql` + query Leak { + diagBooks(where: {}) { + edges { + node { + writtenBy { + bogusSubField + } + } + } + } + } + `, + }); + fail('should have thrown a validation error'); + } catch (e) { + const error = getReturnedError(e); + // Cannot query field "bogusSubField" on type "SecretAuthor". + expect(error.message).not.toContain('SecretAuthor'); + expect(JSON.stringify(error)).not.toContain('SecretAuthor'); + } + }); + + it('should strip pointer target class names from scalar-leaf validation errors without master or maintenance key', async () => { + await setupPointerSchema(parseServer); + + try { + await apolloClient.query({ + query: gql` + query Leak { + diagBooks(where: {}) { + edges { + node { + writtenBy + } + } + } + } + `, + }); + fail('should have thrown a validation error'); + } catch (e) { + const error = getReturnedError(e); + // Field "writtenBy" of type "SecretAuthor" must have a selection of subfields. + expect(error.message).not.toContain('SecretAuthor'); + expect(JSON.stringify(error)).not.toContain('SecretAuthor'); + } + }); + + it('should keep pointer target class names in output-field errors with master key', async () => { + await setupPointerSchema(parseServer); + + try { + await apolloClient.query({ + query: gql` + query Leak { + diagBooks(where: {}) { + edges { + node { + writtenBy + } + } + } + } + `, + context: { + headers: { + 'X-Parse-Master-Key': 'test', + }, + }, + }); + fail('should have thrown a validation error'); + } catch (e) { + const error = getReturnedError(e); + expect(error.message).toContain('SecretAuthor'); + } + }); + + it('should strip pointer target class names from fragment-spread validation errors without master or maintenance key', async () => { + await setupPointerSchema(parseServer); + + try { + await apolloClient.query({ + query: gql` + query Leak { + diagBooks(where: {}) { + edges { + node { + writtenBy { + ... on DiagBook { + id + } + } + } + } + } + } + `, + }); + fail('should have thrown a validation error'); + } catch (e) { + const error = getReturnedError(e); + // Fragment cannot be spread here as objects of type "SecretAuthor" can never be of type "DiagBook". + expect(error.message).not.toContain('SecretAuthor'); + expect(JSON.stringify(error)).not.toContain('SecretAuthor'); + } + }); + + it('should keep caller-referenced input type names in validation errors without master or maintenance key', async () => { + await setupPointerSchema(parseServer); + + try { + await apolloClient.query({ + query: gql` + query Leak($where: DiagBookWhereInput) { + diagBooks(where: $where) { + edges { + node { + id + } + } + } + } + `, + variables: { where: { nonexistentField: { equalTo: 1 } } }, + }); + fail('should have thrown a validation error'); + } catch (e) { + const error = getReturnedError(e); + // The caller referenced DiagBookWhereInput in the operation text, so it is not a + // schema disclosure and must be preserved to keep validation feedback useful. + expect(error.message).toContain('DiagBookWhereInput'); + } + }); }); diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 827b8cccf5..572a6fa71d 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -122,8 +122,82 @@ const stripSchemaCoercionIdentifiers = message => ) : message; -const stripSchemaIdentifiers = message => - stripSchemaCoercionIdentifiers(stripSchemaSuggestion(message)); +// graphql-js also emits base coercion / validation messages that name a nested input +// TYPE without a "Did you mean" clause, so neither strip above reaches them. For a +// Pointer or Relation field the generated input type name embeds the pointer's TARGET +// class (`PointerInput`, `RelationWhereInput`, `CreateFieldsInput`) +// — a class the caller never referenced and cannot derive from the field name they +// supplied — so these templates disclose a schema class name to a caller who has only the +// public application id. Redact the quoted type identifier from those templates UNLESS the +// caller referenced it in the operation text: a type name the caller wrote in the operation +// (e.g. `$where: UserWhereInput`) is not a disclosure, and preserving it keeps the message +// ('... is not defined by type "UserWhereInput".') useful. When the operation text is +// unavailable the identifier is redacted (fail closed). +const stripSchemaTypeIdentifiers = (message, operationText) => { + if (typeof message !== 'string') { return message; } + // A generated type identifier counts as "referenced" (and therefore not a disclosure) only if + // the caller wrote it as a whole token in the operation text. Tokenize the operation on + // non-identifier characters and compare exact tokens rather than building a RegExp from the + // captured name: this avoids substring false-matches (e.g. preserving "AuthorPointerInput" + // because the operation contains "SecretAuthorPointerInput") and any regex injection/ReDoS from + // an unusual captured name. GraphQL list/non-null wrappers ("[", "]", "!") are stripped from the + // captured name so e.g. "SecretAuthorPointerInput!" still matches "$x: SecretAuthorPointerInput!". + // When the operation text is unavailable the type is treated as not referenced (fail closed). + const referencedTokens = + typeof operationText === 'string' + ? new Set(operationText.split(/[^_A-Za-z0-9]+/).filter(Boolean)) + : new Set(); + const isReferenced = typeName => referencedTokens.has(typeName.replace(/[[\]!]/g, '')); + return message + // Input coercion / ValuesOfCorrectTypeRule (variables and inline literals). + .replace(/Expected value of type "([^"]+)"/g, (match, typeName) => + isReferenced(typeName) ? match : 'Expected value of the correct type' + ) + .replace(/Expected type "([^"]+)" to be an object\./g, (match, typeName) => + isReferenced(typeName) ? match : 'Expected an object.' + ) + .replace(/Expected non-nullable type "([^"]+)" not to be null\./g, (match, typeName) => + isReferenced(typeName) ? match : 'Expected a non-null value.' + ) + .replace(/ is not defined by type "([^"]+)"\./g, (match, typeName) => + isReferenced(typeName) ? match : ' is not defined.' + ) + // VariablesInAllowedPositionRule: the position type is the pointer/relation target + // input type; the caller only wrote their own variable's declared type. + .replace(/ used in position expecting type "([^"]+)"\./g, (match, typeName) => + isReferenced(typeName) ? match : ' used in position expecting a different type.' + ) + // FieldsOnCorrectTypeRule: descending into a Pointer/Relation output field names its + // target output object type. + .replace(/Cannot query field ("[^"]*") on type "([^"]+)"\./g, (match, fieldName, typeName) => + isReferenced(typeName) ? match : `Cannot query field ${fieldName}.` + ) + // ScalarLeafsRule: selecting a Pointer/Relation output field with no sub-selection names + // its target output object type. + .replace( + /Field ("[^"]*") of type "([^"]+)" must have a selection of subfields\./g, + (match, fieldName, typeName) => + isReferenced(typeName) ? match : `Field ${fieldName} must have a selection of subfields.` + ) + // PossibleFragmentSpreadsRule: an inline/named fragment on an incompatible type inside a + // Pointer/Relation output field names the target output object type (the parent type). + // Redact each type token the caller did not reference; when both are referenced the + // reconstruction is identical to the original message. + .replace( + /objects of type "([^"]+)" can never be of type "([^"]+)"\./g, + (match, parentType, fragType) => { + const parent = isReferenced(parentType) ? `type "${parentType}"` : 'the parent type'; + const frag = isReferenced(fragType) ? `type "${fragType}"` : 'the given type'; + return `objects of ${parent} can never be of ${frag}.`; + } + ); +}; + +const stripSchemaIdentifiers = (message, operationText) => + stripSchemaTypeIdentifiers( + stripSchemaCoercionIdentifiers(stripSchemaSuggestion(message)), + operationText + ); const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({ requestDidStart: async (requestContext) => ({ @@ -144,10 +218,13 @@ const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({ : body?.kind === 'incremental' ? body.initialResult.errors : undefined; + const operationText = requestContext.request?.query; errors?.forEach(error => { - error.message = stripSchemaIdentifiers(error.message); + error.message = stripSchemaIdentifiers(error.message, operationText); if (Array.isArray(error.extensions?.stacktrace)) { - error.extensions.stacktrace = error.extensions.stacktrace.map(stripSchemaIdentifiers); + error.extensions.stacktrace = error.extensions.stacktrace.map(message => + stripSchemaIdentifiers(message, operationText) + ); } }); },