mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
293f60e5f3 | ||
|
|
2625489a27 | ||
|
|
11010cc02b | ||
|
|
459786fd41 |
@@ -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)
|
||||
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user