mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
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:
@@ -1056,6 +1056,377 @@ describe('ParseGraphQLServer', () => {
|
|||||||
expect(JSON.stringify(error)).not.toContain('secretRequiredField');
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,82 @@ const stripSchemaCoercionIdentifiers = message =>
|
|||||||
)
|
)
|
||||||
: message;
|
: message;
|
||||||
|
|
||||||
const stripSchemaIdentifiers = message =>
|
// graphql-js also emits base coercion / validation messages that name a nested input
|
||||||
stripSchemaCoercionIdentifiers(stripSchemaSuggestion(message));
|
// 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) => ({
|
const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({
|
||||||
requestDidStart: async (requestContext) => ({
|
requestDidStart: async (requestContext) => ({
|
||||||
@@ -104,10 +178,13 @@ const SchemaSuggestionsControlPlugin = (publicIntrospection) => ({
|
|||||||
: body?.kind === 'incremental'
|
: body?.kind === 'incremental'
|
||||||
? body.initialResult.errors
|
? body.initialResult.errors
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const operationText = requestContext.request?.query;
|
||||||
errors?.forEach(error => {
|
errors?.forEach(error => {
|
||||||
error.message = stripSchemaIdentifiers(error.message);
|
error.message = stripSchemaIdentifiers(error.message, operationText);
|
||||||
if (Array.isArray(error.extensions?.stacktrace)) {
|
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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user