fix: GraphQL error messages disclose pointer and relation target class names when public introspection is disabled ([GHSA-r2g6-4f6j-f6rf](https://github.com/parse-community/parse-server/security/advisories/GHSA-r2g6-4f6j-f6rf)) (#10569)

This commit is contained in:
Manuel
2026-07-11 00:28:50 +02:00
committed by GitHub
parent c14da8141b
commit bc863d7085
2 changed files with 452 additions and 4 deletions
+371
View File
@@ -1056,6 +1056,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 (`<Target>PointerInput`, `<Target>RelationWhereInput`,
// `Create<Target>FieldsInput`). 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');
}
});
});
+81 -4
View File
@@ -82,8 +82,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 (`<Target>PointerInput`, `<Target>RelationWhereInput`, `Create<Target>FieldsInput`)
// — 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) => ({
@@ -104,10 +178,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)
);
}
});
},