VYPR
Medium severityNVD Advisory· Published Jun 12, 2026

CVE-2026-53726

CVE-2026-53726

Description

Parse Server's $relatedTo query bypasses protectedFields and ACLs, allowing unauthenticated enumeration of Relation members.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Parse Server's $relatedTo query bypasses protectedFields and ACLs, allowing unauthenticated enumeration of Relation members.

Vulnerability

In Parse Server prior to versions 8.6.80 and 9.9.1-alpha.6, a relation query using the $relatedTo operator could read the membership of a Relation field even when that field was hidden from the requesting client by protectedFields, and even when the object owning the relation was not readable by the client under its ACL or class-level permissions. The request requires only the public API credentials that Parse clients normally carry — no user session, master key, or Cloud Code is needed. This affects applications that rely on protectedFields or object ACLs to keep Relation membership confidential, such as private group memberships, block lists, or account-to-resource associations [3].

Exploitation

An unauthenticated attacker who knows or obtains the owning object's objectId can enumerate the objects linked through a protected relation, or combine the operator with an objectId constraint to use it as a membership oracle — confirming whether a specific object is linked to a private parent. Only public API credentials are required [3].

Impact

Successful exploitation allows an attacker to read the membership of a protected Relation field, bypassing access controls. This compromises the confidentiality of sensitive associations, such as private group memberships or block lists. The attacker does not need any privileged access [3].

Mitigation

The issue has been patched in Parse Server versions 8.6.80 and 9.9.1-alpha.6 [1][2]. The relation query path now authorizes $relatedTo against the owning object before reading the join table, using the caller's authentication context. The relation key is checked against the owning class's protectedFields and the owning object must be readable by the caller; otherwise, the relation returns no results. There is no complete workaround without upgrading; as mitigation, applications can avoid exposing sensitive membership through Relation fields to untrusted clients, or enforce access in a beforeFind trigger [3].

AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

2
efef11bc2dac

fix: Relation `$relatedTo` query bypasses `protectedFields` and owning-object ACL ([GHSA-wmwx-jr2p-4j4r](https://github.com/parse-community/parse-server/security/advisories/GHSA-wmwx-jr2p-4j4r)) (#10494)

https://github.com/parse-community/parse-serverManuelJun 3, 2026via body-scan-shorthand
3 files changed · +390 13
  • benchmark/performance.js+35 0 modified
    @@ -557,6 +557,40 @@ async function benchmarkObjectCreateNestedDenylist(name) {
       });
     }
     
    +/**
    + * Benchmark: $relatedTo relation query (public, non-master)
    + *
    + * Measures a public `$relatedTo` query, which now performs an owning-object
    + * read-access check before reading the relation join table (GHSA-wmwx-jr2p-4j4r).
    + * This captures the cost of that added authorization read on the relation path.
    + */
    +async function benchmarkRelatedToQuery(name) {
    +  const Child = Parse.Object.extend('BenchmarkRelChild');
    +  const children = [];
    +  for (let i = 0; i < 50; i++) {
    +    children.push(new Child({ value: i }));
    +  }
    +  await Parse.Object.saveAll(children, { useMasterKey: true });
    +
    +  // Publicly readable owning object, so the authorized relation path runs fully.
    +  const Parent = Parse.Object.extend('BenchmarkRelParent');
    +  const parent = new Parent({ name: 'benchmark-parent' });
    +  const acl = new Parse.ACL();
    +  acl.setPublicReadAccess(true);
    +  parent.setACL(acl);
    +  parent.relation('members').add(children);
    +  await parent.save(null, { useMasterKey: true });
    +
    +  return measureOperation({
    +    name,
    +    iterations: 1_000,
    +    operation: async () => {
    +      // Non-master query exercises the owning-object read-access check.
    +      await parent.relation('members').query().find();
    +    },
    +  });
    +}
    +
     /**
      * Run all benchmarks
      */
    @@ -582,6 +616,7 @@ async function runBenchmarks() {
           { name: 'Object.saveAll (batch save)', fn: benchmarkBatchSave },
           { name: 'Query.get (by objectId)', fn: benchmarkObjectRead },
           { name: 'Query.find (simple query)', fn: benchmarkSimpleQuery },
    +      { name: 'Query.find ($relatedTo relation)', fn: benchmarkRelatedToQuery },
           { name: 'User.signUp', fn: benchmarkUserSignup },
           { name: 'User.login', fn: benchmarkUserLogin },
           { name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },
    
  • spec/vulnerabilities.spec.js+201 0 modified
    @@ -5507,4 +5507,205 @@ describe('Vulnerabilities', () => {
           expect(req.info.clientSDK).toBeUndefined();
         });
       });
    +
    +  describe('(GHSA-wmwx-jr2p-4j4r) $relatedTo bypasses protectedFields and parent ACL for Relation fields', () => {
    +    let childLinked;
    +    let parentProtectedKey;
    +    let parentPrivate;
    +    let parentPublic;
    +
    +    const relatedToWhere = (parentId, key, extra = {}) => ({
    +      $relatedTo: {
    +        object: { __type: 'Pointer', className: 'RelParent', objectId: parentId },
    +        key,
    +      },
    +      ...extra,
    +    });
    +
    +    const queryChild = (where, headers = {}) =>
    +      request({
    +        method: 'GET',
    +        url: `${Parse.serverURL}/classes/RelChild`,
    +        headers: {
    +          'X-Parse-Application-Id': Parse.applicationId,
    +          'X-Parse-REST-API-Key': 'rest',
    +          ...headers,
    +        },
    +        qs: { where: JSON.stringify(where) },
    +      }).catch(e => e);
    +
    +    beforeEach(async () => {
    +      const schema = new Parse.Schema('RelParent');
    +      schema.addString('name');
    +      schema.addRelation('secretRel', 'RelChild');
    +      schema.addRelation('openRel', 'RelChild');
    +      schema.setCLP({
    +        find: { '*': true },
    +        get: { '*': true },
    +        create: { '*': true },
    +        update: { '*': true },
    +        delete: { '*': true },
    +        addField: {},
    +        // secretRel is a protected Relation field for public clients
    +        protectedFields: { '*': ['secretRel'] },
    +      });
    +      await schema.save();
    +
    +      childLinked = new Parse.Object('RelChild', { value: 'linked child' });
    +      await childLinked.save(null, { useMasterKey: true });
    +
    +      const publicAcl = new Parse.ACL();
    +      publicAcl.setPublicReadAccess(true);
    +
    +      const privateAcl = new Parse.ACL();
    +      privateAcl.setPublicReadAccess(false);
    +      privateAcl.setPublicWriteAccess(false);
    +
    +      // Publicly readable parent whose relation key is protected (isolates the
    +      // protectedFields facet).
    +      parentProtectedKey = new Parse.Object('RelParent', { name: 'protected-key parent' });
    +      parentProtectedKey.setACL(publicAcl);
    +      parentProtectedKey.relation('secretRel').add(childLinked);
    +      await parentProtectedKey.save(null, { useMasterKey: true });
    +
    +      // Parent that is not readable by the public, queried via a non-protected
    +      // relation key (isolates the parent-ACL facet).
    +      parentPrivate = new Parse.Object('RelParent', { name: 'private parent' });
    +      parentPrivate.setACL(privateAcl);
    +      parentPrivate.relation('openRel').add(childLinked);
    +      await parentPrivate.save(null, { useMasterKey: true });
    +
    +      // Publicly readable parent with a non-protected relation key (legitimate
    +      // use that must keep working).
    +      parentPublic = new Parse.Object('RelParent', { name: 'public parent' });
    +      parentPublic.setACL(publicAcl);
    +      parentPublic.relation('openRel').add(childLinked);
    +      await parentPublic.save(null, { useMasterKey: true });
    +    });
    +
    +    it('denies $relatedTo query that references a protected relation field', async () => {
    +      const res = await queryChild(relatedToWhere(parentProtectedKey.id, 'secretRel'));
    +      expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
    +      expect(res.data.error).toBe('Permission denied');
    +    });
    +
    +    it('denies $relatedTo on a protected relation field nested in $or', async () => {
    +      const res = await queryChild({
    +        $or: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
    +      });
    +      expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
    +      expect(res.data.error).toBe('Permission denied');
    +    });
    +
    +    it('denies $relatedTo on a protected relation field nested in $and', async () => {
    +      const res = await queryChild({
    +        $and: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
    +      });
    +      expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
    +      expect(res.data.error).toBe('Permission denied');
    +    });
    +
    +    it('denies $relatedTo on a protected relation field nested in $nor', async () => {
    +      const res = await queryChild({
    +        $nor: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
    +      });
    +      expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
    +      expect(res.data.error).toBe('Permission denied');
    +    });
    +
    +    it('returns no results when the owning object is not readable by the caller', async () => {
    +      const res = await queryChild(relatedToWhere(parentPrivate.id, 'openRel'));
    +      expect(res.data.results).toEqual([]);
    +    });
    +
    +    it('does not act as a membership oracle for an unreadable owning object', async () => {
    +      const res = await queryChild(
    +        relatedToWhere(parentPrivate.id, 'openRel', { objectId: childLinked.id })
    +      );
    +      expect(res.data.results).toEqual([]);
    +    });
    +
    +    it('still returns related objects for a readable parent and non-protected key', async () => {
    +      const res = await queryChild(relatedToWhere(parentPublic.id, 'openRel'));
    +      expect(res.data.results.length).toBe(1);
    +      expect(res.data.results[0].objectId).toBe(childLinked.id);
    +    });
    +
    +    it('allows master key to query a protected relation and an unreadable parent', async () => {
    +      const masterHeaders = { 'X-Parse-Master-Key': Parse.masterKey };
    +      const resProtected = await queryChild(
    +        relatedToWhere(parentProtectedKey.id, 'secretRel'),
    +        masterHeaders
    +      );
    +      expect(resProtected.data.results.length).toBe(1);
    +      const resPrivate = await queryChild(
    +        relatedToWhere(parentPrivate.id, 'openRel'),
    +        masterHeaders
    +      );
    +      expect(resPrivate.data.results.length).toBe(1);
    +    });
    +
    +    it('respects user-level read access to the owning object', async () => {
    +      const userA = await Parse.User.signUp('relUserA', 'pw');
    +      const userB = await Parse.User.signUp('relUserB', 'pw');
    +
    +      const acl = new Parse.ACL();
    +      acl.setReadAccess(userA, true);
    +      const parent = new Parse.Object('RelParent', { name: 'user-scoped parent' });
    +      parent.setACL(acl);
    +      parent.relation('openRel').add(childLinked);
    +      await parent.save(null, { useMasterKey: true });
    +
    +      const resA = await queryChild(relatedToWhere(parent.id, 'openRel'), {
    +        'X-Parse-Session-Token': userA.getSessionToken(),
    +      });
    +      expect(resA.data.results.length).toBe(1);
    +
    +      const resB = await queryChild(relatedToWhere(parent.id, 'openRel'), {
    +        'X-Parse-Session-Token': userB.getSessionToken(),
    +      });
    +      expect(resB.data.results).toEqual([]);
    +    });
    +
    +    it('returns no results when the owning class denies get permission (CLP)', async () => {
    +      // Owning class denies public `get`, so the owning-object read throws
    +      // OPERATION_FORBIDDEN; the relation must then return no results.
    +      const schema = new Parse.Schema('RelParentNoGet');
    +      schema.addRelation('members', 'RelChild');
    +      schema.setCLP({
    +        find: { '*': true },
    +        get: {},
    +        create: { '*': true },
    +        update: { '*': true },
    +        delete: { '*': true },
    +        addField: {},
    +      });
    +      await schema.save();
    +
    +      const acl = new Parse.ACL();
    +      acl.setPublicReadAccess(true);
    +      const parent = new Parse.Object('RelParentNoGet', { name: 'no-get parent' });
    +      parent.setACL(acl);
    +      parent.relation('members').add(childLinked);
    +      await parent.save(null, { useMasterKey: true });
    +
    +      const res = await request({
    +        method: 'GET',
    +        url: `${Parse.serverURL}/classes/RelChild`,
    +        headers: {
    +          'X-Parse-Application-Id': Parse.applicationId,
    +          'X-Parse-REST-API-Key': 'rest',
    +        },
    +        qs: {
    +          where: JSON.stringify({
    +            $relatedTo: {
    +              object: { __type: 'Pointer', className: 'RelParentNoGet', objectId: parent.id },
    +              key: 'members',
    +            },
    +          }),
    +        },
    +      }).catch(e => e);
    +      expect(res.data.results).toEqual([]);
    +    });
    +  });
     });
    
  • src/Controllers/DatabaseController.js+154 13 modified
    @@ -1074,38 +1074,169 @@ class DatabaseController {
     
       // Modifies query so that it no longer has $relatedTo
       // Returns a promise that resolves when query is mutated
    -  reduceRelationKeys(className: string, query: any, queryOptions: any): ?Promise<void> {
    +  reduceRelationKeys(
    +    className: string,
    +    query: any,
    +    queryOptions: any,
    +    auth: any = {},
    +    aclGroup: any[] = [],
    +    isMaster: boolean = false,
    +    schemaController: ?SchemaController.SchemaController
    +  ): ?Promise<void> {
         if (query['$or']) {
           return Promise.all(
             query['$or'].map(aQuery => {
    -          return this.reduceRelationKeys(className, aQuery, queryOptions);
    +          return this.reduceRelationKeys(
    +            className,
    +            aQuery,
    +            queryOptions,
    +            auth,
    +            aclGroup,
    +            isMaster,
    +            schemaController
    +          );
             })
           );
         }
         if (query['$and']) {
           return Promise.all(
             query['$and'].map(aQuery => {
    -          return this.reduceRelationKeys(className, aQuery, queryOptions);
    +          return this.reduceRelationKeys(
    +            className,
    +            aQuery,
    +            queryOptions,
    +            auth,
    +            aclGroup,
    +            isMaster,
    +            schemaController
    +          );
    +        })
    +      );
    +    }
    +    if (Array.isArray(query['$nor'])) {
    +      // Guard with Array.isArray (unlike the legacy $or/$and checks above) so a
    +      // malformed non-array $nor still falls through to validateQuery and yields
    +      // the existing INVALID_QUERY error instead of throwing here.
    +      return Promise.all(
    +        query['$nor'].map(aQuery => {
    +          return this.reduceRelationKeys(
    +            className,
    +            aQuery,
    +            queryOptions,
    +            auth,
    +            aclGroup,
    +            isMaster,
    +            schemaController
    +          );
             })
           );
         }
         var relatedTo = query['$relatedTo'];
         if (relatedTo) {
    -      return this.relatedIds(
    -        relatedTo.object.className,
    -        relatedTo.key,
    -        relatedTo.object.objectId,
    -        queryOptions
    -      )
    -        .then(ids => {
    +      return this.authorizeRelatedToQuery(relatedTo, auth, aclGroup, isMaster, schemaController)
    +        .then(canReadOwningObject => {
               delete query['$relatedTo'];
    -          this.addInObjectIdsIds(ids, query);
    -          return this.reduceRelationKeys(className, query, queryOptions);
    +          if (!canReadOwningObject) {
    +            // The caller is not allowed to read the owning object, so the
    +            // relation must not disclose any linked objects (and must not act
    +            // as a membership oracle for a known related id).
    +            this.addInObjectIdsIds([], query);
    +            return this.reduceRelationKeys(
    +              className,
    +              query,
    +              queryOptions,
    +              auth,
    +              aclGroup,
    +              isMaster,
    +              schemaController
    +            );
    +          }
    +          return this.relatedIds(
    +            relatedTo.object.className,
    +            relatedTo.key,
    +            relatedTo.object.objectId,
    +            queryOptions
    +          ).then(ids => {
    +            this.addInObjectIdsIds(ids, query);
    +            return this.reduceRelationKeys(
    +              className,
    +              query,
    +              queryOptions,
    +              auth,
    +              aclGroup,
    +              isMaster,
    +              schemaController
    +            );
    +          });
             })
             .then(() => {});
         }
       }
     
    +  // Authorizes a `$relatedTo` relation query against the owning object before
    +  // its join table is read by `relatedIds`. Without this check, `$relatedTo`
    +  // bypasses both `protectedFields` and the owning object's ACL/CLP, because
    +  // the downstream protected-field and ACL filters only apply to the queried
    +  // (target) class, never to the owning class referenced by `$relatedTo`.
    +  //
    +  // - Throws `OPERATION_FORBIDDEN` if the relation key is a protected field on
    +  //   the owning class for the caller's auth context (mirrors the protected
    +  //   WHERE-field denial in `RestQuery.denyProtectedFields`).
    +  // - Resolves to `true` if the caller may read the owning object (so the join
    +  //   table read may proceed), or `false` otherwise (so the relation yields no
    +  //   results and cannot be used as a membership oracle).
    +  //
    +  // Master and maintenance requests bypass both checks by design.
    +  authorizeRelatedToQuery(
    +    relatedTo: any,
    +    auth: any = {},
    +    aclGroup: any[] = [],
    +    isMaster: boolean = false,
    +    schemaController: ?SchemaController.SchemaController
    +  ): Promise<boolean> {
    +    if (isMaster) {
    +      return Promise.resolve(true);
    +    }
    +    const owningClassName = relatedTo && relatedTo.object && relatedTo.object.className;
    +    const owningId = relatedTo && relatedTo.object && relatedTo.object.objectId;
    +    const relationKey = relatedTo && relatedTo.key;
    +    return this.loadSchemaIfNeeded(schemaController).then(loadedSchema => {
    +      // 1. The relation key must not be a protected field on the owning class.
    +      const protectedFields =
    +        this.addProtectedFields(loadedSchema, owningClassName, {}, aclGroup, auth) || [];
    +      const rootField = typeof relationKey === 'string' ? relationKey.split('.')[0] : relationKey;
    +      if (protectedFields.includes(relationKey) || protectedFields.includes(rootField)) {
    +        throw createSanitizedError(
    +          Parse.Error.OPERATION_FORBIDDEN,
    +          `This user is not allowed to query ${relationKey} on class ${owningClassName}`,
    +          this.options
    +        );
    +      }
    +      // 2. The caller must be able to read the owning object itself. A read with
    +      //    the caller's auth context applies the owning class CLP, the object
    +      //    ACL and pointer permissions. Any "not authorized" or "not found"
    +      //    outcome maps to "cannot read", so the relation returns no results.
    +      return this.find(
    +        owningClassName,
    +        { objectId: owningId },
    +        { acl: aclGroup, limit: 1, keys: ['objectId'], op: 'get' },
    +        auth,
    +        loadedSchema
    +      )
    +        .then(results => Array.isArray(results) && results.length > 0)
    +        .catch(error => {
    +          if (
    +            error instanceof Parse.Error &&
    +            (error.code === Parse.Error.OPERATION_FORBIDDEN ||
    +              error.code === Parse.Error.OBJECT_NOT_FOUND)
    +          ) {
    +            return false;
    +          }
    +          throw error;
    +        });
    +    });
    +  }
    +
       addInObjectIdsIds(ids: ?Array<string> = null, query: any) {
         const idsFromString: ?Array<string> =
           typeof query.objectId === 'string' ? [query.objectId] : null;
    @@ -1269,7 +1400,17 @@ class DatabaseController {
                 ? Promise.resolve()
                 : schemaController.validatePermission(className, aclGroup, op)
               )
    -            .then(() => this.reduceRelationKeys(className, query, queryOptions))
    +            .then(() =>
    +              this.reduceRelationKeys(
    +                className,
    +                query,
    +                queryOptions,
    +                auth,
    +                aclGroup,
    +                isMaster,
    +                schemaController
    +              )
    +            )
                 .then(() => this.reduceInRelation(className, query, schemaController))
                 .then(() => {
                   let protectedFields;
    
43658f1fd836

fix: Relation `$relatedTo` query bypasses `protectedFields` and owning-object ACL ([GHSA-wmwx-jr2p-4j4r](https://github.com/parse-community/parse-server/security/advisories/GHSA-wmwx-jr2p-4j4r)) (#10493)

https://github.com/parse-community/parse-serverManuelJun 3, 2026via body-scan-shorthand
3 files changed · +390 13
  • benchmark/performance.js+35 0 modified
    @@ -829,6 +829,40 @@ async function benchmarkObjectCreateNestedDenylist(name) {
       });
     }
     
    +/**
    + * Benchmark: $relatedTo relation query (public, non-master)
    + *
    + * Measures a public `$relatedTo` query, which now performs an owning-object
    + * read-access check before reading the relation join table (GHSA-wmwx-jr2p-4j4r).
    + * This captures the cost of that added authorization read on the relation path.
    + */
    +async function benchmarkRelatedToQuery(name) {
    +  const Child = Parse.Object.extend('BenchmarkRelChild');
    +  const children = [];
    +  for (let i = 0; i < 50; i++) {
    +    children.push(new Child({ value: i }));
    +  }
    +  await Parse.Object.saveAll(children, { useMasterKey: true });
    +
    +  // Publicly readable owning object, so the authorized relation path runs fully.
    +  const Parent = Parse.Object.extend('BenchmarkRelParent');
    +  const parent = new Parent({ name: 'benchmark-parent' });
    +  const acl = new Parse.ACL();
    +  acl.setPublicReadAccess(true);
    +  parent.setACL(acl);
    +  parent.relation('members').add(children);
    +  await parent.save(null, { useMasterKey: true });
    +
    +  return measureOperation({
    +    name,
    +    iterations: 1_000,
    +    operation: async () => {
    +      // Non-master query exercises the owning-object read-access check.
    +      await parent.relation('members').query().find();
    +    },
    +  });
    +}
    +
     /**
      * Run all benchmarks
      */
    @@ -856,6 +890,7 @@ async function runBenchmarks() {
           { name: 'Object.saveAll (batch save)', fn: benchmarkBatchSave },
           { name: 'Query.get (by objectId)', fn: benchmarkObjectRead },
           { name: 'Query.find (simple query)', fn: benchmarkSimpleQuery },
    +      { name: 'Query.find ($relatedTo relation)', fn: benchmarkRelatedToQuery },
           { name: 'User.signUp', fn: benchmarkUserSignup },
           { name: 'User.login', fn: benchmarkUserLogin },
           { name: 'Query.include (parallel pointers)', fn: benchmarkQueryWithIncludeParallel },
    
  • spec/vulnerabilities.spec.js+201 0 modified
    @@ -2307,6 +2307,207 @@ describe('Vulnerabilities', () => {
         });
       });
     
    +  describe('(GHSA-wmwx-jr2p-4j4r) $relatedTo bypasses protectedFields and parent ACL for Relation fields', () => {
    +    let childLinked;
    +    let parentProtectedKey;
    +    let parentPrivate;
    +    let parentPublic;
    +
    +    const relatedToWhere = (parentId, key, extra = {}) => ({
    +      $relatedTo: {
    +        object: { __type: 'Pointer', className: 'RelParent', objectId: parentId },
    +        key,
    +      },
    +      ...extra,
    +    });
    +
    +    const queryChild = (where, headers = {}) =>
    +      request({
    +        method: 'GET',
    +        url: `${Parse.serverURL}/classes/RelChild`,
    +        headers: {
    +          'X-Parse-Application-Id': Parse.applicationId,
    +          'X-Parse-REST-API-Key': 'rest',
    +          ...headers,
    +        },
    +        qs: { where: JSON.stringify(where) },
    +      }).catch(e => e);
    +
    +    beforeEach(async () => {
    +      const schema = new Parse.Schema('RelParent');
    +      schema.addString('name');
    +      schema.addRelation('secretRel', 'RelChild');
    +      schema.addRelation('openRel', 'RelChild');
    +      schema.setCLP({
    +        find: { '*': true },
    +        get: { '*': true },
    +        create: { '*': true },
    +        update: { '*': true },
    +        delete: { '*': true },
    +        addField: {},
    +        // secretRel is a protected Relation field for public clients
    +        protectedFields: { '*': ['secretRel'] },
    +      });
    +      await schema.save();
    +
    +      childLinked = new Parse.Object('RelChild', { value: 'linked child' });
    +      await childLinked.save(null, { useMasterKey: true });
    +
    +      const publicAcl = new Parse.ACL();
    +      publicAcl.setPublicReadAccess(true);
    +
    +      const privateAcl = new Parse.ACL();
    +      privateAcl.setPublicReadAccess(false);
    +      privateAcl.setPublicWriteAccess(false);
    +
    +      // Publicly readable parent whose relation key is protected (isolates the
    +      // protectedFields facet).
    +      parentProtectedKey = new Parse.Object('RelParent', { name: 'protected-key parent' });
    +      parentProtectedKey.setACL(publicAcl);
    +      parentProtectedKey.relation('secretRel').add(childLinked);
    +      await parentProtectedKey.save(null, { useMasterKey: true });
    +
    +      // Parent that is not readable by the public, queried via a non-protected
    +      // relation key (isolates the parent-ACL facet).
    +      parentPrivate = new Parse.Object('RelParent', { name: 'private parent' });
    +      parentPrivate.setACL(privateAcl);
    +      parentPrivate.relation('openRel').add(childLinked);
    +      await parentPrivate.save(null, { useMasterKey: true });
    +
    +      // Publicly readable parent with a non-protected relation key (legitimate
    +      // use that must keep working).
    +      parentPublic = new Parse.Object('RelParent', { name: 'public parent' });
    +      parentPublic.setACL(publicAcl);
    +      parentPublic.relation('openRel').add(childLinked);
    +      await parentPublic.save(null, { useMasterKey: true });
    +    });
    +
    +    it('denies $relatedTo query that references a protected relation field', async () => {
    +      const res = await queryChild(relatedToWhere(parentProtectedKey.id, 'secretRel'));
    +      expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
    +      expect(res.data.error).toBe('Permission denied');
    +    });
    +
    +    it('denies $relatedTo on a protected relation field nested in $or', async () => {
    +      const res = await queryChild({
    +        $or: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
    +      });
    +      expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
    +      expect(res.data.error).toBe('Permission denied');
    +    });
    +
    +    it('denies $relatedTo on a protected relation field nested in $and', async () => {
    +      const res = await queryChild({
    +        $and: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
    +      });
    +      expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
    +      expect(res.data.error).toBe('Permission denied');
    +    });
    +
    +    it('denies $relatedTo on a protected relation field nested in $nor', async () => {
    +      const res = await queryChild({
    +        $nor: [relatedToWhere(parentProtectedKey.id, 'secretRel')],
    +      });
    +      expect(res.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
    +      expect(res.data.error).toBe('Permission denied');
    +    });
    +
    +    it('returns no results when the owning object is not readable by the caller', async () => {
    +      const res = await queryChild(relatedToWhere(parentPrivate.id, 'openRel'));
    +      expect(res.data.results).toEqual([]);
    +    });
    +
    +    it('does not act as a membership oracle for an unreadable owning object', async () => {
    +      const res = await queryChild(
    +        relatedToWhere(parentPrivate.id, 'openRel', { objectId: childLinked.id })
    +      );
    +      expect(res.data.results).toEqual([]);
    +    });
    +
    +    it('still returns related objects for a readable parent and non-protected key', async () => {
    +      const res = await queryChild(relatedToWhere(parentPublic.id, 'openRel'));
    +      expect(res.data.results.length).toBe(1);
    +      expect(res.data.results[0].objectId).toBe(childLinked.id);
    +    });
    +
    +    it('allows master key to query a protected relation and an unreadable parent', async () => {
    +      const masterHeaders = { 'X-Parse-Master-Key': Parse.masterKey };
    +      const resProtected = await queryChild(
    +        relatedToWhere(parentProtectedKey.id, 'secretRel'),
    +        masterHeaders
    +      );
    +      expect(resProtected.data.results.length).toBe(1);
    +      const resPrivate = await queryChild(
    +        relatedToWhere(parentPrivate.id, 'openRel'),
    +        masterHeaders
    +      );
    +      expect(resPrivate.data.results.length).toBe(1);
    +    });
    +
    +    it('respects user-level read access to the owning object', async () => {
    +      const userA = await Parse.User.signUp('relUserA', 'pw');
    +      const userB = await Parse.User.signUp('relUserB', 'pw');
    +
    +      const acl = new Parse.ACL();
    +      acl.setReadAccess(userA, true);
    +      const parent = new Parse.Object('RelParent', { name: 'user-scoped parent' });
    +      parent.setACL(acl);
    +      parent.relation('openRel').add(childLinked);
    +      await parent.save(null, { useMasterKey: true });
    +
    +      const resA = await queryChild(relatedToWhere(parent.id, 'openRel'), {
    +        'X-Parse-Session-Token': userA.getSessionToken(),
    +      });
    +      expect(resA.data.results.length).toBe(1);
    +
    +      const resB = await queryChild(relatedToWhere(parent.id, 'openRel'), {
    +        'X-Parse-Session-Token': userB.getSessionToken(),
    +      });
    +      expect(resB.data.results).toEqual([]);
    +    });
    +
    +    it('returns no results when the owning class denies get permission (CLP)', async () => {
    +      // Owning class denies public `get`, so the owning-object read throws
    +      // OPERATION_FORBIDDEN; the relation must then return no results.
    +      const schema = new Parse.Schema('RelParentNoGet');
    +      schema.addRelation('members', 'RelChild');
    +      schema.setCLP({
    +        find: { '*': true },
    +        get: {},
    +        create: { '*': true },
    +        update: { '*': true },
    +        delete: { '*': true },
    +        addField: {},
    +      });
    +      await schema.save();
    +
    +      const acl = new Parse.ACL();
    +      acl.setPublicReadAccess(true);
    +      const parent = new Parse.Object('RelParentNoGet', { name: 'no-get parent' });
    +      parent.setACL(acl);
    +      parent.relation('members').add(childLinked);
    +      await parent.save(null, { useMasterKey: true });
    +
    +      const res = await request({
    +        method: 'GET',
    +        url: `${Parse.serverURL}/classes/RelChild`,
    +        headers: {
    +          'X-Parse-Application-Id': Parse.applicationId,
    +          'X-Parse-REST-API-Key': 'rest',
    +        },
    +        qs: {
    +          where: JSON.stringify({
    +            $relatedTo: {
    +              object: { __type: 'Pointer', className: 'RelParentNoGet', objectId: parent.id },
    +              key: 'members',
    +            },
    +          }),
    +        },
    +      }).catch(e => e);
    +      expect(res.data.results).toEqual([]);
    +    });
    +  });
    +
       describe('(GHSA-j7mm-f4rv-6q6q) Protected fields bypass via LiveQuery dot-notation WHERE', () => {
         let obj;
     
    
  • src/Controllers/DatabaseController.js+154 13 modified
    @@ -1144,38 +1144,169 @@ class DatabaseController {
     
       // Modifies query so that it no longer has $relatedTo
       // Returns a promise that resolves when query is mutated
    -  reduceRelationKeys(className: string, query: any, queryOptions: any): ?Promise<void> {
    +  reduceRelationKeys(
    +    className: string,
    +    query: any,
    +    queryOptions: any,
    +    auth: any = {},
    +    aclGroup: any[] = [],
    +    isMaster: boolean = false,
    +    schemaController: ?SchemaController.SchemaController
    +  ): ?Promise<void> {
         if (query['$or']) {
           return Promise.all(
             query['$or'].map(aQuery => {
    -          return this.reduceRelationKeys(className, aQuery, queryOptions);
    +          return this.reduceRelationKeys(
    +            className,
    +            aQuery,
    +            queryOptions,
    +            auth,
    +            aclGroup,
    +            isMaster,
    +            schemaController
    +          );
             })
           );
         }
         if (query['$and']) {
           return Promise.all(
             query['$and'].map(aQuery => {
    -          return this.reduceRelationKeys(className, aQuery, queryOptions);
    +          return this.reduceRelationKeys(
    +            className,
    +            aQuery,
    +            queryOptions,
    +            auth,
    +            aclGroup,
    +            isMaster,
    +            schemaController
    +          );
    +        })
    +      );
    +    }
    +    if (Array.isArray(query['$nor'])) {
    +      // Guard with Array.isArray (unlike the legacy $or/$and checks above) so a
    +      // malformed non-array $nor still falls through to validateQuery and yields
    +      // the existing INVALID_QUERY error instead of throwing here.
    +      return Promise.all(
    +        query['$nor'].map(aQuery => {
    +          return this.reduceRelationKeys(
    +            className,
    +            aQuery,
    +            queryOptions,
    +            auth,
    +            aclGroup,
    +            isMaster,
    +            schemaController
    +          );
             })
           );
         }
         var relatedTo = query['$relatedTo'];
         if (relatedTo) {
    -      return this.relatedIds(
    -        relatedTo.object.className,
    -        relatedTo.key,
    -        relatedTo.object.objectId,
    -        queryOptions
    -      )
    -        .then(ids => {
    +      return this.authorizeRelatedToQuery(relatedTo, auth, aclGroup, isMaster, schemaController)
    +        .then(canReadOwningObject => {
               delete query['$relatedTo'];
    -          this.addInObjectIdsIds(ids, query);
    -          return this.reduceRelationKeys(className, query, queryOptions);
    +          if (!canReadOwningObject) {
    +            // The caller is not allowed to read the owning object, so the
    +            // relation must not disclose any linked objects (and must not act
    +            // as a membership oracle for a known related id).
    +            this.addInObjectIdsIds([], query);
    +            return this.reduceRelationKeys(
    +              className,
    +              query,
    +              queryOptions,
    +              auth,
    +              aclGroup,
    +              isMaster,
    +              schemaController
    +            );
    +          }
    +          return this.relatedIds(
    +            relatedTo.object.className,
    +            relatedTo.key,
    +            relatedTo.object.objectId,
    +            queryOptions
    +          ).then(ids => {
    +            this.addInObjectIdsIds(ids, query);
    +            return this.reduceRelationKeys(
    +              className,
    +              query,
    +              queryOptions,
    +              auth,
    +              aclGroup,
    +              isMaster,
    +              schemaController
    +            );
    +          });
             })
             .then(() => {});
         }
       }
     
    +  // Authorizes a `$relatedTo` relation query against the owning object before
    +  // its join table is read by `relatedIds`. Without this check, `$relatedTo`
    +  // bypasses both `protectedFields` and the owning object's ACL/CLP, because
    +  // the downstream protected-field and ACL filters only apply to the queried
    +  // (target) class, never to the owning class referenced by `$relatedTo`.
    +  //
    +  // - Throws `OPERATION_FORBIDDEN` if the relation key is a protected field on
    +  //   the owning class for the caller's auth context (mirrors the protected
    +  //   WHERE-field denial in `RestQuery.denyProtectedFields`).
    +  // - Resolves to `true` if the caller may read the owning object (so the join
    +  //   table read may proceed), or `false` otherwise (so the relation yields no
    +  //   results and cannot be used as a membership oracle).
    +  //
    +  // Master and maintenance requests bypass both checks by design.
    +  authorizeRelatedToQuery(
    +    relatedTo: any,
    +    auth: any = {},
    +    aclGroup: any[] = [],
    +    isMaster: boolean = false,
    +    schemaController: ?SchemaController.SchemaController
    +  ): Promise<boolean> {
    +    if (isMaster) {
    +      return Promise.resolve(true);
    +    }
    +    const owningClassName = relatedTo && relatedTo.object && relatedTo.object.className;
    +    const owningId = relatedTo && relatedTo.object && relatedTo.object.objectId;
    +    const relationKey = relatedTo && relatedTo.key;
    +    return this.loadSchemaIfNeeded(schemaController).then(loadedSchema => {
    +      // 1. The relation key must not be a protected field on the owning class.
    +      const protectedFields =
    +        this.addProtectedFields(loadedSchema, owningClassName, {}, aclGroup, auth) || [];
    +      const rootField = typeof relationKey === 'string' ? relationKey.split('.')[0] : relationKey;
    +      if (protectedFields.includes(relationKey) || protectedFields.includes(rootField)) {
    +        throw createSanitizedError(
    +          Parse.Error.OPERATION_FORBIDDEN,
    +          `This user is not allowed to query ${relationKey} on class ${owningClassName}`,
    +          this.options
    +        );
    +      }
    +      // 2. The caller must be able to read the owning object itself. A read with
    +      //    the caller's auth context applies the owning class CLP, the object
    +      //    ACL and pointer permissions. Any "not authorized" or "not found"
    +      //    outcome maps to "cannot read", so the relation returns no results.
    +      return this.find(
    +        owningClassName,
    +        { objectId: owningId },
    +        { acl: aclGroup, limit: 1, keys: ['objectId'], op: 'get' },
    +        auth,
    +        loadedSchema
    +      )
    +        .then(results => Array.isArray(results) && results.length > 0)
    +        .catch(error => {
    +          if (
    +            error instanceof Parse.Error &&
    +            (error.code === Parse.Error.OPERATION_FORBIDDEN ||
    +              error.code === Parse.Error.OBJECT_NOT_FOUND)
    +          ) {
    +            return false;
    +          }
    +          throw error;
    +        });
    +    });
    +  }
    +
       addInObjectIdsIds(ids: ?Array<string> = null, query: any) {
         const idsFromString: ?Array<string> =
           typeof query.objectId === 'string' ? [query.objectId] : null;
    @@ -1341,7 +1472,17 @@ class DatabaseController {
                 ? Promise.resolve()
                 : schemaController.validatePermission(className, aclGroup, op)
               )
    -            .then(() => this.reduceRelationKeys(className, query, queryOptions))
    +            .then(() =>
    +              this.reduceRelationKeys(
    +                className,
    +                query,
    +                queryOptions,
    +                auth,
    +                aclGroup,
    +                isMaster,
    +                schemaController
    +              )
    +            )
                 .then(() => this.reduceInRelation(className, query, schemaController))
                 .then(() => {
                   let protectedFields;
    

Vulnerability mechanics

Root cause

"Missing authorization check in the `$relatedTo` query processing allowed reading Relation membership without verifying the caller's access to the owning object or the protected status of the relation field."

Attack vector

An unauthenticated attacker who knows the `objectId` of a parent object can send a REST API query using the `$relatedTo` operator against a child class. Because the server did not verify that the caller could read the owning object or that the relation key was protected, the attacker could enumerate all objects linked through a protected relation or use the query as a membership oracle to confirm whether a specific object is linked to a private parent. The request requires only the public API credentials that Parse clients normally carry — no user session, master key, or Cloud Code is needed.

Affected code

The vulnerability resides in `DatabaseController.reduceRelationKeys` and the missing `authorizeRelatedToQuery` method in `src/Controllers/DatabaseController.js`. The `$relatedTo` operator was processed without checking whether the caller could read the owning object or whether the relation key was a protected field on the owning class.

What the fix does

The patch introduces a new `authorizeRelatedToQuery` method in `DatabaseController` that performs two checks before the relation join table is read. First, it verifies that the relation key is not a protected field on the owning class for the caller's auth context, throwing `OPERATION_FORBIDDEN` if it is. Second, it performs a read of the owning object using the caller's credentials to ensure the caller has access; if the read fails (due to ACL, CLP, or object not found), the relation returns an empty result set instead of leaking membership. The `auth`, `aclGroup`, `isMaster`, and `schemaController` parameters are threaded through `reduceRelationKeys` to enable these checks. Master key requests bypass both checks by design.

Preconditions

  • inputThe attacker must know the objectId of a parent object that owns a Relation field.
  • authThe attacker must have access to the public API credentials (Application ID and REST API Key) that Parse clients normally carry.
  • configThe target application must rely on protectedFields or object ACLs to keep Relation membership confidential.

Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.