VYPR
High severity7.5NVD Advisory· Published Mar 31, 2026· Updated Apr 1, 2026

CVE-2026-34784

CVE-2026-34784

Description

Parse Server is an open source backend that can be deployed to any infrastructure that can run Node.js. Prior to versions 8.6.71 and 9.7.1-alpha.1, file downloads via HTTP Range requests bypass the afterFind(Parse.File) trigger and its validators on storage adapters that support streaming (e.g. the default GridFS adapter). This allows access to files that should be protected by afterFind trigger authorization logic or built-in validators such as requireUser. This issue has been patched in versions 8.6.71 and 9.7.1-alpha.1.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
parse-servernpm
>= 9.0.0, < 9.7.1-alpha.19.7.1-alpha.1
parse-servernpm
< 8.6.718.6.71

Affected products

1

Patches

2
053109b3ee71

fix: Streaming file download bypasses afterFind file trigger authorization ([GHSA-hpm8-9qx6-jvwv](https://github.com/parse-community/parse-server/security/advisories/GHSA-hpm8-9qx6-jvwv)) (#10362)

2 files changed · +97 4
  • spec/vulnerabilities.spec.js+65 0 modified
    @@ -4947,4 +4947,69 @@ describe('Vulnerabilities', () => {
           });
         });
       });
    +
    +  describe('(GHSA-hpm8-9qx6-jvwv) Ranged file download bypasses afterFind(Parse.File) trigger and validators', () => {
    +    it_only_db('mongo')('enforces afterFind requireUser validator on streaming file download', async () => {
    +      const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain');
    +      await file.save({ useMasterKey: true });
    +      Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true });
    +      const response = await request({
    +        url: file.url(),
    +        headers: {
    +          'X-Parse-Application-Id': 'test',
    +          'X-Parse-REST-API-Key': 'rest',
    +          'Range': 'bytes=0-2',
    +        },
    +      }).catch(e => e);
    +      expect(response.status).toBe(403);
    +    });
    +
    +    it('enforces afterFind requireUser validator on non-streaming file download', async () => {
    +      const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain');
    +      await file.save({ useMasterKey: true });
    +      Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true });
    +      const response = await request({
    +        url: file.url(),
    +        headers: {
    +          'X-Parse-Application-Id': 'test',
    +          'X-Parse-REST-API-Key': 'rest',
    +        },
    +      }).catch(e => e);
    +      expect(response.status).toBe(403);
    +    });
    +
    +    it_only_db('mongo')('allows streaming file download when afterFind requireUser validator passes', async () => {
    +      const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain');
    +      await file.save({ useMasterKey: true });
    +      const user = await Parse.User.signUp('username', 'password');
    +      Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true });
    +      const response = await request({
    +        url: file.url(),
    +        headers: {
    +          'X-Parse-Application-Id': 'test',
    +          'X-Parse-REST-API-Key': 'rest',
    +          'X-Parse-Session-Token': user.getSessionToken(),
    +          'Range': 'bytes=0-2',
    +        },
    +      }).catch(e => e);
    +      expect(response.status).toBe(206);
    +    });
    +
    +    it_only_db('mongo')('enforces afterFind custom authorization on streaming file download', async () => {
    +      const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain');
    +      await file.save({ useMasterKey: true });
    +      Parse.Cloud.afterFind(Parse.File, () => {
    +        throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Access denied');
    +      });
    +      const response = await request({
    +        url: file.url(),
    +        headers: {
    +          'X-Parse-Application-Id': 'test',
    +          'X-Parse-REST-API-Key': 'rest',
    +          'Range': 'bytes=0-2',
    +        },
    +      }).catch(e => e);
    +      expect(response.status).toBe(403);
    +    });
    +  });
     });
    
  • src/Routers/FilesRouter.js+32 4 modified
    @@ -5,6 +5,7 @@ import Config from '../Config';
     import logger from '../logger';
     const triggers = require('../triggers');
     const Utils = require('../Utils');
    +const auth = require('../Auth');
     import { createSanitizedHttpError } from '../Error';
     
     export class FilesRouter {
    @@ -40,6 +41,22 @@ export class FilesRouter {
         return router;
       }
     
    +  static async _resolveAuth(req, config) {
    +    const sessionToken = req.get('X-Parse-Session-Token');
    +    if (!sessionToken) {
    +      return null;
    +    }
    +    try {
    +      return await auth.getAuthForSessionToken({
    +        config,
    +        sessionToken,
    +        installationId: req.get('X-Parse-Installation-Id'),
    +      });
    +    } catch {
    +      return null;
    +    }
    +  }
    +
       async getHandler(req, res) {
         const config = Config.get(req.params.appId);
         if (!config) {
    @@ -54,18 +71,28 @@ export class FilesRouter {
           const mime = (await import('mime')).default;
           let contentType = mime.getType(filename);
           let file = new Parse.File(filename, { base64: '' }, contentType);
    +      const fileAuth = await FilesRouter._resolveAuth(req, config);
           const triggerResult = await triggers.maybeRunFileTrigger(
             triggers.Types.beforeFind,
             { file },
             config,
    -        req.auth
    +        fileAuth
           );
           if (triggerResult?.file?._name) {
             filename = triggerResult?.file?._name;
             contentType = mime.getType(filename);
           }
     
           if (isFileStreamable(req, filesController)) {
    +        const afterFind = await triggers.maybeRunFileTrigger(
    +          triggers.Types.afterFind,
    +          { file, forceDownload: false },
    +          config,
    +          fileAuth
    +        );
    +        if (afterFind?.forceDownload) {
    +          res.set('Content-Disposition', `attachment;filename=${afterFind.file?._name || filename}`);
    +        }
             filesController.handleFileStream(config, filename, req, res, contentType).catch(() => {
               res.status(404);
               res.set('Content-Type', 'text/plain');
    @@ -87,7 +114,7 @@ export class FilesRouter {
             triggers.Types.afterFind,
             { file, forceDownload: false },
             config,
    -        req.auth
    +        fileAuth
           );
     
           if (afterFind?.file) {
    @@ -326,11 +353,12 @@ export class FilesRouter {
           const { filesController } = config;
           let { filename } = req.params;
           const file = new Parse.File(filename, { base64: '' });
    +      const fileAuth = await FilesRouter._resolveAuth(req, config);
           const triggerResult = await triggers.maybeRunFileTrigger(
             triggers.Types.beforeFind,
             { file },
             config,
    -        req.auth
    +        fileAuth
           );
           if (triggerResult?.file?._name) {
             filename = triggerResult.file._name;
    @@ -346,7 +374,7 @@ export class FilesRouter {
             triggers.Types.afterFind,
             { file },
             config,
    -        req.auth
    +        fileAuth
           );
           res.status(200);
           res.json(data);
    
a0b0c69fc44f

fix: Streaming file download bypasses afterFind file trigger authorization ([GHSA-hpm8-9qx6-jvwv](https://github.com/parse-community/parse-server/security/advisories/GHSA-hpm8-9qx6-jvwv)) (#10361)

2 files changed · +98 5
  • spec/vulnerabilities.spec.js+65 0 modified
    @@ -5538,4 +5538,69 @@ describe('Vulnerabilities', () => {
           expect(contextAfterDelete.isAdmin).toBeUndefined();
         });
       });
    +
    +  describe('(GHSA-hpm8-9qx6-jvwv) Ranged file download bypasses afterFind(Parse.File) trigger and validators', () => {
    +    it_only_db('mongo')('enforces afterFind requireUser validator on streaming file download', async () => {
    +      const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain');
    +      await file.save({ useMasterKey: true });
    +      Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true });
    +      const response = await request({
    +        url: file.url(),
    +        headers: {
    +          'X-Parse-Application-Id': 'test',
    +          'X-Parse-REST-API-Key': 'rest',
    +          'Range': 'bytes=0-2',
    +        },
    +      }).catch(e => e);
    +      expect(response.status).toBe(403);
    +    });
    +
    +    it('enforces afterFind requireUser validator on non-streaming file download', async () => {
    +      const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain');
    +      await file.save({ useMasterKey: true });
    +      Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true });
    +      const response = await request({
    +        url: file.url(),
    +        headers: {
    +          'X-Parse-Application-Id': 'test',
    +          'X-Parse-REST-API-Key': 'rest',
    +        },
    +      }).catch(e => e);
    +      expect(response.status).toBe(403);
    +    });
    +
    +    it_only_db('mongo')('allows streaming file download when afterFind requireUser validator passes', async () => {
    +      const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain');
    +      await file.save({ useMasterKey: true });
    +      const user = await Parse.User.signUp('username', 'password');
    +      Parse.Cloud.afterFind(Parse.File, () => {}, { requireUser: true });
    +      const response = await request({
    +        url: file.url(),
    +        headers: {
    +          'X-Parse-Application-Id': 'test',
    +          'X-Parse-REST-API-Key': 'rest',
    +          'X-Parse-Session-Token': user.getSessionToken(),
    +          'Range': 'bytes=0-2',
    +        },
    +      }).catch(e => e);
    +      expect(response.status).toBe(206);
    +    });
    +
    +    it_only_db('mongo')('enforces afterFind custom authorization on streaming file download', async () => {
    +      const file = new Parse.File('secret.txt', [1, 2, 3], 'text/plain');
    +      await file.save({ useMasterKey: true });
    +      Parse.Cloud.afterFind(Parse.File, () => {
    +        throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Access denied');
    +      });
    +      const response = await request({
    +        url: file.url(),
    +        headers: {
    +          'X-Parse-Application-Id': 'test',
    +          'X-Parse-REST-API-Key': 'rest',
    +          'Range': 'bytes=0-2',
    +        },
    +      }).catch(e => e);
    +      expect(response.status).toBe(403);
    +    });
    +  });
     });
    
  • src/Routers/FilesRouter.js+33 5 modified
    @@ -5,6 +5,7 @@ import Config from '../Config';
     import logger from '../logger';
     const triggers = require('../triggers');
     const Utils = require('../Utils');
    +const auth = require('../Auth');
     import { Readable } from 'stream';
     import { createSanitizedHttpError } from '../Error';
     
    @@ -120,6 +121,22 @@ export class FilesRouter {
         return Array.isArray(parts) ? parts.join('/') : parts;
       }
     
    +  static async _resolveAuth(req, config) {
    +    const sessionToken = req.get('X-Parse-Session-Token');
    +    if (!sessionToken) {
    +      return null;
    +    }
    +    try {
    +      return await auth.getAuthForSessionToken({
    +        config,
    +        sessionToken,
    +        installationId: req.get('X-Parse-Installation-Id'),
    +      });
    +    } catch {
    +      return null;
    +    }
    +  }
    +
       static validateDirectory(directory) {
         if (typeof directory !== 'string') {
           return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory must be a string.');
    @@ -177,11 +194,12 @@ export class FilesRouter {
           const mime = (await import('mime')).default;
           let contentType = mime.getType(filename);
           let file = new Parse.File(filename, { base64: '' }, contentType);
    +      const fileAuth = await FilesRouter._resolveAuth(req, config);
           const triggerResult = await triggers.maybeRunFileTrigger(
             triggers.Types.beforeFind,
             { file },
             config,
    -        req.auth
    +        fileAuth
           );
           if (triggerResult?.file?._name) {
             filename = triggerResult?.file?._name;
    @@ -191,7 +209,16 @@ export class FilesRouter {
           const defaultResponseHeaders = { 'X-Content-Type-Options': 'nosniff' };
     
           if (isFileStreamable(req, filesController)) {
    -        for (const [key, value] of Object.entries(defaultResponseHeaders)) {
    +        const afterFind = await triggers.maybeRunFileTrigger(
    +          triggers.Types.afterFind,
    +          { file, forceDownload: false, responseHeaders: { ...defaultResponseHeaders } },
    +          config,
    +          fileAuth
    +        );
    +        if (afterFind?.forceDownload) {
    +          res.set('Content-Disposition', `attachment;filename=${afterFind.file?._name || filename}`);
    +        }
    +        for (const [key, value] of Object.entries(afterFind?.responseHeaders ?? defaultResponseHeaders)) {
               res.set(key, value);
             }
             filesController.handleFileStream(config, filename, req, res, contentType).catch(() => {
    @@ -215,7 +242,7 @@ export class FilesRouter {
             triggers.Types.afterFind,
             { file, forceDownload: false, responseHeaders: { ...defaultResponseHeaders } },
             config,
    -        req.auth
    +        fileAuth
           );
     
           if (afterFind?.file) {
    @@ -736,11 +763,12 @@ export class FilesRouter {
           const { filesController } = config;
           let filename = FilesRouter._getFilenameFromParams(req);
           const file = new Parse.File(filename, { base64: '' });
    +      const fileAuth = await FilesRouter._resolveAuth(req, config);
           const triggerResult = await triggers.maybeRunFileTrigger(
             triggers.Types.beforeFind,
             { file },
             config,
    -        req.auth
    +        fileAuth
           );
           if (triggerResult?.file?._name) {
             filename = triggerResult.file._name;
    @@ -756,7 +784,7 @@ export class FilesRouter {
             triggers.Types.afterFind,
             { file },
             config,
    -        req.auth
    +        fileAuth
           );
           res.status(200);
           res.json(data);
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.