Compare commits

...
Author SHA1 Message Date
semantic-release-bot 293f60e5f3 chore(release): 9.10.0-alpha.4 [skip ci]
# [9.10.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.3...9.10.0-alpha.4) (2026-07-07)

### Bug Fixes

* 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)) ([#10563](https://github.com/parse-community/parse-server/issues/10563)) ([2625489](https://github.com/parse-community/parse-server/commit/2625489a27c07aed1283c5dc9c5e8f8f53a9e44c))
2026-07-07 21:51:49 +00:00
Manuel 2625489a27 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)) (#10563) 2026-07-07 23:50:55 +02:00
semantic-release-bot 11010cc02b chore(release): 9.10.0-alpha.3 [skip ci]
# [9.10.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.2...9.10.0-alpha.3) (2026-07-07)

### Bug Fixes

* NumberOrBoolean config option (cluster) value not coerced from env/CLI ([#10531](https://github.com/parse-community/parse-server/issues/10531)) ([459786f](https://github.com/parse-community/parse-server/commit/459786fd41cc835ec650d951258f011737cdb4c9))
2026-07-07 13:38:05 +00:00
Daniel 459786fd41 fix: NumberOrBoolean config option (cluster) value not coerced from env/CLI (#10531) 2026-07-07 15:37:06 +02:00
9 changed files with 168 additions and 23 deletions
+14
View File
@@ -1,3 +1,17 @@
# [9.10.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.3...9.10.0-alpha.4) (2026-07-07)
### Bug Fixes
* 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)) ([#10563](https://github.com/parse-community/parse-server/issues/10563)) ([2625489](https://github.com/parse-community/parse-server/commit/2625489a27c07aed1283c5dc9c5e8f8f53a9e44c))
# [9.10.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.2...9.10.0-alpha.3) (2026-07-07)
### Bug Fixes
* NumberOrBoolean config option (cluster) value not coerced from env/CLI ([#10531](https://github.com/parse-community/parse-server/issues/10531)) ([459786f](https://github.com/parse-community/parse-server/commit/459786fd41cc835ec650d951258f011737cdb4c9))
# [9.10.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.10.0-alpha.1...9.10.0-alpha.2) (2026-06-25)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "parse-server",
"version": "9.10.0-alpha.2",
"version": "9.10.0-alpha.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "parse-server",
"version": "9.10.0-alpha.2",
"version": "9.10.0-alpha.4",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "parse-server",
"version": "9.10.0-alpha.2",
"version": "9.10.0-alpha.4",
"description": "An express module providing a Parse-compatible API server",
"main": "lib/index.js",
"repository": {
+1 -1
View File
@@ -177,7 +177,7 @@ function mapperFor(elt, t) {
return wrap(t.identifier('moduleOrObjectParser'));
}
if (type == 'NumberOrBoolean') {
return wrap(t.identifier('numberOrBooleanParser'));
return t.callExpression(wrap(t.identifier('numberOrBoolParser')), [t.stringLiteral(elt.name)]);
}
if (type == 'NumberOrString') {
return t.callExpression(wrap(t.identifier('numberOrStringParser')), [t.stringLiteral(elt.name)]);
+10 -2
View File
@@ -178,7 +178,7 @@ describe('definitions', () => {
if (typeof definition.required !== 'undefined') {
expect(typeof definition.required).toBe('boolean');
}
if (typeof definition.action !== 'undefined') {
if ('action' in definition) {
expect(typeof definition.action).toBe('function');
}
}
@@ -189,6 +189,14 @@ describe('definitions', () => {
definitions.facebookAppIds.action();
}).toThrow();
});
it('should coerce the NumberOrBoolean cluster option value', () => {
const action = definitions.cluster.action;
expect(typeof action).toBe('function');
expect(action('2')).toBe(2);
expect(action('true')).toBe(true);
expect(action('false')).toBe(false);
});
});
describe('LiveQuery definitions', () => {
@@ -203,7 +211,7 @@ describe('LiveQuery definitions', () => {
if (typeof definition.required !== 'undefined') {
expect(typeof definition.required).toBe('boolean');
}
if (typeof definition.action !== 'undefined') {
if ('action' in definition) {
expect(typeof definition.action).toBe('function');
}
}
+106
View File
@@ -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');
}
});
});
+5 -4
View File
@@ -81,9 +81,10 @@ describe('buildConfigDefinitions', () => {
expect(result.property.name).toBe('moduleOrObjectParser');
});
it('should return numberOrBooleanParser for NumberOrBoolean GenericTypeAnnotation', () => {
it('should return numberOrBoolParser call expression for NumberOrBoolean GenericTypeAnnotation', () => {
const mockElement = {
type: 'GenericTypeAnnotation',
name: 'cluster',
typeAnnotation: {
id: {
name: 'NumberOrBoolean',
@@ -93,9 +94,9 @@ describe('buildConfigDefinitions', () => {
const result = mapperFor(mockElement, t);
expect(t.isMemberExpression(result)).toBe(true);
expect(result.object.name).toBe('parsers');
expect(result.property.name).toBe('numberOrBooleanParser');
expect(t.isCallExpression(result)).toBe(true);
expect(result.callee.property.name).toBe('numberOrBoolParser');
expect(result.arguments[0].value).toBe('cluster');
});
it('should return numberOrStringParser call expression for NumberOrString GenericTypeAnnotation', () => {
+28 -12
View File
@@ -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;
}
@@ -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);
}
});
},
}),
});
+1 -1
View File
@@ -139,7 +139,7 @@ module.exports.ParseServerOptions = {
cluster: {
env: 'PARSE_SERVER_CLUSTER',
help: 'Run with cluster, optionally set the number of processes default to os.cpus().length',
action: parsers.numberOrBooleanParser,
action: parsers.numberOrBoolParser('cluster'),
},
collectionPrefix: {
env: 'PARSE_SERVER_COLLECTION_PREFIX',