diff --git a/package-lock.json b/package-lock.json index 6bdbe3072d..466ef98597 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "graphql": "16.13.2", "graphql-list-fields": "2.0.4", "graphql-relay": "0.10.2", - "graphql-upload": "15.0.2", + "graphql-upload": "17.0.0", "intersect": "1.0.1", "jsonwebtoken": "9.0.3", "jwks-rsa": "3.2.0", @@ -12981,11 +12981,12 @@ ] }, "node_modules/fs-capacitor": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-6.2.0.tgz", - "integrity": "sha512-nKcE1UduoSKX27NSZlg879LdQc94OtbOsEmKMN2MBNudXREvijRKx2GEBsTMTfws+BrbkJoEuynbGSVRSpauvw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-8.0.0.tgz", + "integrity": "sha512-+Lk6iSKajdGw+7XYxUkwIzreJ2G1JFlYOdnKJv5PzwFLVsoJYBpCuS7WPIUSNT1IbQaEWT1nhYU63Ud03DyzLA==", + "license": "MIT", "engines": { - "node": ">=10" + "node": "^14.17.0 || >=16.0.0" } }, "node_modules/fs-constants": { @@ -13866,26 +13867,27 @@ } }, "node_modules/graphql-upload": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/graphql-upload/-/graphql-upload-15.0.2.tgz", - "integrity": "sha512-ufJAkZJBKWRDD/4wJR3VZMy9QWTwqIYIciPtCEF5fCNgWF+V1p7uIgz+bP2YYLiS4OJBhCKR8rnqE/Wg3XPUiw==", + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/graphql-upload/-/graphql-upload-17.0.0.tgz", + "integrity": "sha512-AI42S1UR1mdqg+LQ7KqGbrgcf4l9gpPu/R0drM4vSA5C94NfIjYyCeCdpktEledvZoAL8JURLLeB53++WACo1w==", + "license": "MIT", "dependencies": { "@types/busboy": "^1.5.0", "@types/node": "*", "@types/object-path": "^0.11.1", "busboy": "^1.6.0", - "fs-capacitor": "^6.2.0", + "fs-capacitor": "^8.0.0", "http-errors": "^2.0.0", "object-path": "^0.11.8" }, "engines": { - "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=22.0.0" }, "funding": { "url": "https://github.com/sponsors/jaydenseric" }, "peerDependencies": { - "@types/express": "^4.0.29", + "@types/express": "4.0.29 - 5", "@types/koa": "^2.11.4", "graphql": "^16.3.0" }, @@ -35703,9 +35705,9 @@ "dev": true }, "fs-capacitor": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-6.2.0.tgz", - "integrity": "sha512-nKcE1UduoSKX27NSZlg879LdQc94OtbOsEmKMN2MBNudXREvijRKx2GEBsTMTfws+BrbkJoEuynbGSVRSpauvw==" + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-8.0.0.tgz", + "integrity": "sha512-+Lk6iSKajdGw+7XYxUkwIzreJ2G1JFlYOdnKJv5PzwFLVsoJYBpCuS7WPIUSNT1IbQaEWT1nhYU63Ud03DyzLA==" }, "fs-constants": { "version": "1.0.0", @@ -36331,15 +36333,15 @@ } }, "graphql-upload": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/graphql-upload/-/graphql-upload-15.0.2.tgz", - "integrity": "sha512-ufJAkZJBKWRDD/4wJR3VZMy9QWTwqIYIciPtCEF5fCNgWF+V1p7uIgz+bP2YYLiS4OJBhCKR8rnqE/Wg3XPUiw==", + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/graphql-upload/-/graphql-upload-17.0.0.tgz", + "integrity": "sha512-AI42S1UR1mdqg+LQ7KqGbrgcf4l9gpPu/R0drM4vSA5C94NfIjYyCeCdpktEledvZoAL8JURLLeB53++WACo1w==", "requires": { "@types/busboy": "^1.5.0", "@types/node": "*", "@types/object-path": "^0.11.1", "busboy": "^1.6.0", - "fs-capacitor": "^6.2.0", + "fs-capacitor": "^8.0.0", "http-errors": "^2.0.0", "object-path": "^0.11.8" } diff --git a/package.json b/package.json index fb164dcf58..a05f4a2725 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "graphql": "16.13.2", "graphql-list-fields": "2.0.4", "graphql-relay": "0.10.2", - "graphql-upload": "15.0.2", + "graphql-upload": "17.0.0", "intersect": "1.0.1", "jsonwebtoken": "9.0.3", "jwks-rsa": "3.2.0", diff --git a/spec/FileNameNormalization.spec.js b/spec/FileNameNormalization.spec.js new file mode 100644 index 0000000000..ac4696e0e7 --- /dev/null +++ b/spec/FileNameNormalization.spec.js @@ -0,0 +1,116 @@ +'use strict'; + +const GridFSBucketAdapter = require('../lib/Adapters/Files/GridFSBucketAdapter') + .GridFSBucketAdapter; +const request = require('../lib/request'); + +const databaseURI = 'mongodb://localhost:27017/parse'; + +describe_only_db('mongo')('Unicode filename normalization', () => { + beforeEach(async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + const db = await gfsAdapter._connect(); + await db.dropDatabase(); + await gfsAdapter.handleShutdown(); + }); + + it('normalizes each path segment for direct GridFS adapter operations', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + const decomposedFilename = 'cafe\u0301.txt'; + const normalizedFilename = 'caf\u00e9.txt'; + const storedFilename = `docs/${normalizedFilename}`; + + await gfsAdapter.createFile(`docs/${decomposedFilename}`, 'normalized content', 'text/plain', { + metadata: {}, + }); + + const bucket = await gfsAdapter._getBucket(); + let documents = await bucket.find({ filename: storedFilename }).toArray(); + expect(documents.length).toBe(1); + + const metadata = await gfsAdapter.getMetadata(`docs/${decomposedFilename}`); + expect(metadata).toEqual({ metadata: {} }); + + const data = await gfsAdapter.getFileData(`docs/${decomposedFilename}`); + expect(data.toString('utf8')).toBe('normalized content'); + + await gfsAdapter.deleteFile(`docs/${decomposedFilename}`); + documents = await bucket.find({ filename: storedFilename }).toArray(); + expect(documents.length).toBe(0); + }); + + it('normalizes filenames across upload, metadata, download, and delete routes', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + await reconfigureServer({ + filesAdapter: gfsAdapter, + preserveFileName: true, + }); + + const decomposedFilename = 'cafe\u0301.txt'; + const normalizedFilename = 'caf\u00e9.txt'; + const requestedFilename = encodeURIComponent(decomposedFilename); + + const createResponse = await request({ + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + url: `http://localhost:8378/1/files/${requestedFilename}`, + body: 'normalized content', + }); + expect(createResponse.data.name).toBe(normalizedFilename); + expect(createResponse.data.url).toBe( + `http://localhost:8378/1/files/test/${encodeURIComponent(normalizedFilename)}` + ); + + const bucket = await gfsAdapter._getBucket(); + let documents = await bucket.find({ filename: normalizedFilename }).toArray(); + expect(documents.length).toBe(1); + + const metadataResponse = await request({ + method: 'GET', + url: `http://localhost:8378/1/files/test/metadata/${requestedFilename}`, + }); + expect(metadataResponse.data).toEqual({ metadata: {} }); + + const downloadResponse = await request({ + method: 'GET', + url: `http://localhost:8378/1/files/test/${requestedFilename}`, + }); + expect(downloadResponse.text).toBe('normalized content'); + + const deleteResponse = await request({ + method: 'DELETE', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + url: `http://localhost:8378/1/files/${requestedFilename}`, + }); + expect(deleteResponse.status).toBe(200); + + documents = await bucket.find({ filename: normalizedFilename }).toArray(); + expect(documents.length).toBe(0); + }); + + it('rejects path traversal in metadata download routes', async () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + await reconfigureServer({ + filesAdapter: gfsAdapter, + preserveFileName: true, + }); + + try { + await request({ + method: 'GET', + url: 'http://localhost:8378/1/files/test/metadata/..%2F..%2F..%2Fetc%2Fpasswd', + }); + fail('should have rejected path traversal'); + } catch (error) { + expect(error.status).toBe(400); + expect(error.data.code).toBe(Parse.Error.INVALID_FILE_NAME); + } + }); +}); diff --git a/spec/FilesController.spec.js b/spec/FilesController.spec.js index 30acf7d13c..c980c503d2 100644 --- a/spec/FilesController.spec.js +++ b/spec/FilesController.spec.js @@ -5,6 +5,10 @@ const GridFSBucketAdapter = require('../lib/Adapters/Files/GridFSBucketAdapter') .GridFSBucketAdapter; const Config = require('../lib/Config'); const FilesController = require('../lib/Controllers/FilesController').default; +const { + validateFilename, + validateFilepath, +} = require('../lib/Adapters/Files/FilesAdapter'); const databaseURI = 'mongodb://localhost:27017/parse'; const mockAdapter = { @@ -151,7 +155,7 @@ describe('FilesController', () => { return 'Bad file! No biscuit!'; }; const filesController = new FilesController(mockAdapter); - const error = filesController.validateFilename(); + const error = filesController.validateFilename('test.txt'); expect(typeof error).toBe('object'); expect(error.message.indexOf('biscuit')).toBe(13); expect(error.code).toBe(Parse.Error.INVALID_FILE_NAME); @@ -218,4 +222,44 @@ describe('FilesController', () => { expect(gridFSAdapter.validateFilename(fileName)).not.toBe(null); done(); }); + + it('should allow accented characters in file names', done => { + const gridFSAdapter = new GridFSBucketAdapter('mongodb://localhost:27017/parse'); + const fileName = 'café.txt'; + expect(gridFSAdapter.validateFilename(fileName)).toBe(null); + done(); + }); + + it('rejects non-string filenames without throwing', () => { + for (const bad of [null, undefined, 42, {}]) { + const error = validateFilename(bad); + expect(error).not.toBeNull(); + expect(error.code).toBe(Parse.Error.INVALID_FILE_NAME); + expect(error.message).toMatch(/string/i); + } + }); + + it('rejects non-string filenames from FilesController without throwing', () => { + const filesController = new FilesController(mockAdapter); + const error = filesController.validateFilename(); + expect(typeof error).toBe('object'); + expect(error.code).toBe(Parse.Error.INVALID_FILE_NAME); + expect(error.message).toMatch(/string/i); + }); + + it('accepts NFC and NFD accented filenames after normalization', () => { + expect(validateFilename('caf\u00e9.txt')).toBeNull(); + expect(validateFilename('cafe\u0301.txt')).toBeNull(); + }); + + it('validates multi-segment filepaths', () => { + expect(validateFilepath('docs/caf\u00e9.txt')).toBeNull(); + expect(validateFilepath(`docs/cafe\u0301.txt`)).toBeNull(); + expect(validateFilepath('a..b.txt')).toBeNull(); + expect(validateFilepath('docs/a..b.txt')).toBeNull(); + for (const bad of ['foo/../bar', '..', 'foo//bar', '/foo', 'foo/']) { + expect(validateFilepath(bad)).not.toBeNull(); + expect(validateFilepath(bad).code).toBe(Parse.Error.INVALID_FILE_NAME); + } + }); }); diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index 5fa13ce9a9..664eb9df19 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -469,6 +469,28 @@ describe('Parse.File testing', () => { }); }); + it('allows accented filename characters', done => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/caf%C3%A9.txt', + body: 'accented filename', + }).then(response => { + const b = response.data; + expect(b.name).toMatch(/_café.txt$/); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*caf%C3%A9.txt$/); + request({ url: b.url }).then(response => { + expect(response.text).toEqual('accented filename'); + done(); + }); + }, fail); + }); + it('validates filename length', done => { const headers = { 'Content-Type': 'text/plain', diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 634bcf3d23..0d5aadac7d 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -7465,6 +7465,71 @@ describe('ParseGraphQLServer', () => { expect(res.status).toEqual(200); expect(await res.text()).toEqual('My File Content'); }); + + it('should preserve accented characters in uploaded filenames', async () => { + const clientMutationId = uuidv4(); + + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + }); + await createGQLFromParseServer(parseServer); + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: ` + mutation CreateFile($input: CreateFileInput!) { + createFile(input: $input) { + clientMutationId + fileInfo { + name + url + } + } + } + `, + variables: { + input: { + clientMutationId, + upload: null, + }, + }, + }) + ); + body.append('map', JSON.stringify({ 1: ['variables.input.upload'] })); + body.append('1', 'My File Content', { + filename: 'café.txt', + contentType: 'text/plain', + }); + + let res = await fetch('http://localhost:13377/graphql', { + method: 'POST', + headers, + body, + }); + + expect(res.status).toEqual(200); + + const result = JSON.parse(await res.text()); + + expect(result.errors).toBeUndefined(); + expect(result.data?.createFile).not.toBeNull(); + if (result.errors || !result.data?.createFile) { + return; + } + expect(result.data.createFile.clientMutationId).toEqual(clientMutationId); + expect(result.data.createFile.fileInfo.name).toEqual( + jasmine.stringMatching(/_café.txt$/) + ); + expect(result.data.createFile.fileInfo.url).toEqual( + jasmine.stringMatching(/_caf%C3%A9.txt$/) + ); + + res = await fetch(result.data.createFile.fileInfo.url); + + expect(res.status).toEqual(200); + expect(await res.text()).toEqual('My File Content'); + }); }); }); diff --git a/src/Adapters/Files/FilesAdapter.js b/src/Adapters/Files/FilesAdapter.js index 57f816521b..d378353aef 100644 --- a/src/Adapters/Files/FilesAdapter.js +++ b/src/Adapters/Files/FilesAdapter.js @@ -101,6 +101,18 @@ export class FilesAdapter { // getMetadata(filename: string): Promise {} } +export const RESERVED_FILEPATH_SEGMENTS = ['metadata']; + +export function normalizeFilename(filename: any): any { + if (typeof filename !== 'string') { + return filename; + } + return filename + .split('/') + .map(segment => segment.normalize('NFC')) + .join('/'); +} + /** * Simple filename validation * @@ -108,15 +120,68 @@ export class FilesAdapter { * @returns {null|Parse.Error} */ export function validateFilename(filename): ?Parse.Error { + if (!filename || typeof filename !== 'string') { + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename must be a string.'); + } + filename = normalizeFilename(filename); if (filename.length > 128) { return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename too long.'); } - const regx = /^[_a-zA-Z0-9][a-zA-Z0-9@. ~_-]*$/; + const regx = /^[_\p{L}\p{N}][\p{L}\p{N}@. ~_-]*$/u; if (!filename.match(regx)) { return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename contains invalid characters.'); } return null; } +/** + * Validate a stored file path that may contain directory segments. + * + * @param filepath + * @returns {null|Parse.Error} + */ +export function validateFilepath(filepath): ?Parse.Error { + if (!filepath || typeof filepath !== 'string') { + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename must be a string.'); + } + const normalized = normalizeFilename(filepath); + const segments = normalized.split('/'); + if (segments.includes('..')) { + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'File path must not contain "..".'); + } + if (normalized.startsWith('/') || normalized.endsWith('/')) { + return new Parse.Error( + Parse.Error.INVALID_FILE_NAME, + 'File path must not start or end with "/".' + ); + } + if (normalized.includes('//')) { + return new Parse.Error( + Parse.Error.INVALID_FILE_NAME, + 'File path must not contain consecutive slashes.' + ); + } + const firstSegment = segments[0]; + if (RESERVED_FILEPATH_SEGMENTS.includes(firstSegment)) { + return new Parse.Error( + Parse.Error.INVALID_FILE_NAME, + `File path must not start with reserved segment "${firstSegment}".` + ); + } + for (const segment of segments) { + if (!segment) { + return new Parse.Error( + Parse.Error.INVALID_FILE_NAME, + 'File path must not contain empty segments.' + ); + } + const error = validateFilename(segment); + if (error) { + return error; + } + } + return null; +} + export default FilesAdapter; diff --git a/src/Adapters/Files/GridFSBucketAdapter.js b/src/Adapters/Files/GridFSBucketAdapter.js index 0236bec219..b244801c50 100644 --- a/src/Adapters/Files/GridFSBucketAdapter.js +++ b/src/Adapters/Files/GridFSBucketAdapter.js @@ -8,7 +8,7 @@ // @flow-disable-next import { MongoClient, GridFSBucket, Db } from 'mongodb'; -import { FilesAdapter, validateFilename } from './FilesAdapter'; +import { FilesAdapter, normalizeFilename, validateFilename } from './FilesAdapter'; import defaults, { ParseServerDatabaseOptions } from '../../defaults'; const crypto = require('crypto'); @@ -78,6 +78,7 @@ export class GridFSBucketAdapter extends FilesAdapter { // For a given config object, filename, and data, store a file // Returns a promise async createFile(filename: string, data, contentType, options = {}) { + filename = normalizeFilename(filename); const bucket = await this._getBucket(); const stream = await bucket.openUploadStream(filename, { metadata: options.metadata, @@ -135,6 +136,7 @@ export class GridFSBucketAdapter extends FilesAdapter { } async deleteFile(filename: string) { + filename = normalizeFilename(filename); const bucket = await this._getBucket(); const documents = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray(); if (documents.length === 0) { @@ -148,6 +150,7 @@ export class GridFSBucketAdapter extends FilesAdapter { } async getFileData(filename: string) { + filename = normalizeFilename(filename); const bucket = await this._getBucket(); const stream = bucket.openDownloadStreamByName(filename); stream.read(); @@ -221,11 +224,13 @@ export class GridFSBucketAdapter extends FilesAdapter { } getFileLocation(config, filename) { + filename = normalizeFilename(filename); const encodedFilename = filename.split('/').map(encodeURIComponent).join('/'); return config.mount + '/files/' + config.applicationId + '/' + encodedFilename; } async getMetadata(filename) { + filename = normalizeFilename(filename); const bucket = await this._getBucket(); const files = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray(); if (files.length === 0) { @@ -236,6 +241,7 @@ export class GridFSBucketAdapter extends FilesAdapter { } async handleFileStream(filename: string, req, res, contentType) { + filename = normalizeFilename(filename); const bucket = await this._getBucket(); const files = await bucket.find({ filename }, { batchSize: this._batchSize }).toArray(); if (files.length === 0) { diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 2c73eb365f..f79457299c 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -1,7 +1,12 @@ // FilesController.js import { randomHexString } from '../cryptoUtils'; import AdaptableController from './AdaptableController'; -import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter'; +import { + normalizeFilename, + validateFilename, + validateFilepath, + FilesAdapter, +} from '../Adapters/Files/FilesAdapter'; import path from 'path'; const Parse = require('parse/node').Parse; @@ -10,8 +15,12 @@ const legacyFilesRegex = new RegExp( ); export class FilesController extends AdaptableController { + normalizeFilename(filename) { + return normalizeFilename(filename); + } + getFileData(config, filename) { - return this.adapter.getFileData(filename); + return this.adapter.getFileData(this.normalizeFilename(filename)); } async createFile(config, filename, data, contentType, options) { @@ -35,6 +44,8 @@ export class FilesController extends AdaptableController { delete options.directory; } + filename = this.normalizeFilename(filename); + // Fallback: buffer stream for adapters that don't support streaming if (typeof data?.pipe === 'function' && !this.adapter.supportsStreaming) { data = await new Promise((resolve, reject) => { @@ -54,12 +65,12 @@ export class FilesController extends AdaptableController { } deleteFile(config, filename) { - return this.adapter.deleteFile(filename); + return this.adapter.deleteFile(this.normalizeFilename(filename)); } getMetadata(filename) { if (typeof this.adapter.getMetadata === 'function') { - return this.adapter.getMetadata(filename); + return this.adapter.getMetadata(this.normalizeFilename(filename)); } return Promise.resolve({}); } @@ -110,10 +121,14 @@ export class FilesController extends AdaptableController { } handleFileStream(config, filename, req, res, contentType) { - return this.adapter.handleFileStream(filename, req, res, contentType); + return this.adapter.handleFileStream(this.normalizeFilename(filename), req, res, contentType); } validateFilename(filename) { + if (!filename || typeof filename !== 'string') { + return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename must be a string.'); + } + filename = this.normalizeFilename(filename); if (typeof this.adapter.validateFilename === 'function') { const error = this.adapter.validateFilename(filename); if (typeof error !== 'string') { @@ -123,6 +138,10 @@ export class FilesController extends AdaptableController { } return validateFilename(filename); } + + validateFilepath(filepath) { + return validateFilepath(filepath); + } } export default FilesController; diff --git a/src/GraphQL/ParseGraphQLSchema.js b/src/GraphQL/ParseGraphQLSchema.js index 5ecdd78de5..6191b732f9 100644 --- a/src/GraphQL/ParseGraphQLSchema.js +++ b/src/GraphQL/ParseGraphQLSchema.js @@ -130,7 +130,7 @@ class ParseGraphQLSchema { this.graphQLSchemaDirectives = {}; this.relayNodeInterface = null; - defaultGraphQLTypes.load(this); + await defaultGraphQLTypes.load(this); defaultRelaySchema.load(this); schemaTypes.load(this); diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 9d18f87d94..c8a3734707 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -1,4 +1,3 @@ -import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js'; import { ApolloServer } from '@apollo/server'; import { expressMiddleware } from '@as-integrations/express5'; import { ApolloServerPluginCacheControlDisabled } from '@apollo/server/plugin/disabled'; @@ -10,6 +9,7 @@ import defaultLogger from '../logger'; import { ParseGraphQLSchema } from './ParseGraphQLSchema'; import ParseGraphQLController, { ParseGraphQLConfig } from '../Controllers/ParseGraphQLController'; import { createComplexityValidationPlugin } from './helpers/queryComplexity'; +import { createGraphQLUploadMiddleware } from './helpers/graphqlUpload'; const hasTypeIntrospection = (query) => { @@ -236,7 +236,7 @@ class ParseGraphQLServer { app.use(this.config.graphQLPath, handleParseErrors); app.use( this.config.graphQLPath, - graphqlUploadExpress({ + createGraphQLUploadMiddleware({ maxFileSize: this._transformMaxUploadSizeToBytes( this.parseServer.config.maxUploadSize || '20mb' ), diff --git a/src/GraphQL/helpers/graphqlUpload.js b/src/GraphQL/helpers/graphqlUpload.js new file mode 100644 index 0000000000..2cfb36d750 --- /dev/null +++ b/src/GraphQL/helpers/graphqlUpload.js @@ -0,0 +1,51 @@ +// Cache the dynamic imports so the ESM-only graphql-upload modules are +// resolved once and then reused by both schema loading and request handling. +let graphqlUploadModulesPromise; + +const loadGraphQLUploadModules = async () => { + if (!graphqlUploadModulesPromise) { + graphqlUploadModulesPromise = Promise.all([ + import('graphql-upload/GraphQLUpload.mjs'), + import('graphql-upload/processRequest.mjs'), + ]).then(([{ default: GraphQLUpload }, { default: processRequest }]) => ({ + GraphQLUpload, + processRequest, + })); + } + + return graphqlUploadModulesPromise; +}; + +// Expose the Upload scalar lazily so the rest of Parse Server can stay on the +// current module system while graphql-upload is now ESM-only. +const getGraphQLUpload = async () => { + const { GraphQLUpload } = await loadGraphQLUploadModules(); + return GraphQLUpload; +}; + +const createGraphQLUploadMiddleware = options => { + const uploadOptions = { + ...options, + // Decode multipart filename parameters as UTF-8 so filenames like + // "cafe.txt" with accents don't arrive as mojibake. + defParamCharset: 'utf8', + }; + + return async (req, res, next) => { + if (!req.is || !req.is('multipart/form-data')) { + return next(); + } + + try { + const { processRequest } = await loadGraphQLUploadModules(); + // graphql-upload parses the multipart body and populates req.body with + // Upload promises before Apollo handles the GraphQL operation. + req.body = await processRequest(req, res, uploadOptions); + return next(); + } catch (error) { + return next(error); + } + }; +}; + +export { createGraphQLUploadMiddleware, getGraphQLUpload }; diff --git a/src/GraphQL/loaders/defaultGraphQLTypes.js b/src/GraphQL/loaders/defaultGraphQLTypes.js index d24047329c..ec3b5ac71f 100644 --- a/src/GraphQL/loaders/defaultGraphQLTypes.js +++ b/src/GraphQL/loaders/defaultGraphQLTypes.js @@ -15,8 +15,8 @@ import { GraphQLUnionType, } from 'graphql'; import { toGlobalId } from 'graphql-relay'; -import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'; import Utils from '../../Utils'; +import { getGraphQLUpload } from '../helpers/graphqlUpload'; class TypeValidationError extends Error { constructor(value, type) { @@ -356,21 +356,25 @@ const FILE_INFO = new GraphQLObjectType({ }, }); -const FILE_INPUT = new GraphQLInputObjectType({ - name: 'FileInput', - description: - 'If this field is set to null the file will be unlinked (the file will not be deleted on cloud storage).', - fields: { - file: { - description: 'A File Scalar can be an url or a FileInfo object.', - type: FILE, - }, - upload: { - description: 'Use this field if you want to create a new file.', - type: GraphQLUpload, +let GraphQLUpload; +let FILE_INPUT; + +const createFileInputType = graphQLUpload => + new GraphQLInputObjectType({ + name: 'FileInput', + description: + 'If this field is set to null the file will be unlinked (the file will not be deleted on cloud storage).', + fields: { + file: { + description: 'A File Scalar can be an url or a FileInfo object.', + type: FILE, + }, + upload: { + description: 'Use this field if you want to create a new file.', + type: graphQLUpload, + }, }, - }, -}); + }); const GEO_POINT_FIELDS = { latitude: { @@ -1219,7 +1223,11 @@ const loadArrayResult = (parseGraphQLSchema, parseClassesArray) => { parseGraphQLSchema.graphQLTypes.push(ARRAY_RESULT); }; -const load = parseGraphQLSchema => { +const load = async parseGraphQLSchema => { + GraphQLUpload = await getGraphQLUpload(); + if (!FILE_INPUT) { + FILE_INPUT = createFileInputType(GraphQLUpload); + } parseGraphQLSchema.addGraphQLType(GraphQLUpload, true); parseGraphQLSchema.addGraphQLType(ANY, true); parseGraphQLSchema.addGraphQLType(OBJECT, true); diff --git a/src/GraphQL/loaders/filesMutations.js b/src/GraphQL/loaders/filesMutations.js index 8439dfeb4f..b81d8f1bfd 100644 --- a/src/GraphQL/loaders/filesMutations.js +++ b/src/GraphQL/loaders/filesMutations.js @@ -20,13 +20,14 @@ const handleUpload = async (upload, config) => { try { const ext = mime.getExtension(mimetype); const fullFileName = filename.endsWith(`.${ext}`) ? filename : `${filename}.${ext}`; + const encodedFileName = fullFileName.split('/').map(encodeURIComponent).join('/'); const serverUrl = new URL(config.serverURL); const fileInfo = await new Promise((resolve, reject) => { const req = request( { hostname: serverUrl.hostname, port: serverUrl.port, - path: `${serverUrl.pathname}/files/${fullFileName}`, + path: `${serverUrl.pathname}/files/${encodedFileName}`, method: 'POST', headers, }, @@ -37,9 +38,19 @@ const handleUpload = async (upload, config) => { }); res.on('end', () => { try { - resolve(JSON.parse(data)); - } catch { - reject(new Parse.Error(Parse.error, data)); + const parsedData = JSON.parse(data); + if (res.statusCode < 200 || res.statusCode >= 300) { + reject( + new Parse.Error( + parsedData.code || Parse.Error.FILE_SAVE_ERROR, + parsedData.error || data + ) + ); + return; + } + resolve(parsedData); + } catch (error) { + reject(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, error?.message || data)); } }); } diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index df6f710135..e3a610cb5d 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -7,6 +7,7 @@ const triggers = require('../triggers'); const Utils = require('../Utils'); import { Readable } from 'stream'; import { createSanitizedHttpError } from '../Error'; +import { RESERVED_FILEPATH_SEGMENTS } from '../Adapters/Files/FilesAdapter'; /** * Wraps a readable stream in a Readable that enforces a byte size limit. @@ -83,7 +84,7 @@ export function createSizeLimitedStream(source, maxBytes) { // Segments that conflict with sub-routes under GET /files/:appId/*. If a file // directory starts with one of these, its URL would match the wrong route // handler. Update this list when adding new sub-routes to expressRouter(). -export const RESERVED_DIRECTORY_SEGMENTS = ['metadata']; +export const RESERVED_DIRECTORY_SEGMENTS = RESERVED_FILEPATH_SEGMENTS; export class FilesRouter { expressRouter({ maxUploadSize = '20Mb' } = {}) { @@ -219,9 +220,16 @@ export class FilesRouter { FilesRouter._validateFileDownload(req, config); + const filesController = config.filesController; let filename = FilesRouter._getFilenameFromParams(req); + filename = filesController.normalizeFilename(filename); + const filepathError = filesController.validateFilepath(filename); + if (filepathError) { + res.status(400); + res.json({ code: filepathError.code, error: filepathError.message }); + return; + } try { - const filesController = config.filesController; const mime = (await import('mime')).default; let contentType = mime.getType(filename); let file = new Parse.File(filename, { base64: '' }, contentType); @@ -233,7 +241,13 @@ export class FilesRouter { fileAuth ); if (triggerResult?.file?._name) { - filename = triggerResult?.file?._name; + filename = filesController.normalizeFilename(triggerResult.file._name); + const renamedPathError = filesController.validateFilepath(filename); + if (renamedPathError) { + res.status(400); + res.json({ code: renamedPathError.code, error: renamedPathError.message }); + return; + } contentType = mime.getType(filename); } @@ -401,7 +415,8 @@ export class FilesRouter { } } const filesController = config.filesController; - const { filename } = req.params; + const filename = filesController.normalizeFilename(req.params.filename); + req.params.filename = filename; const contentType = req.get('Content-type'); const error = filesController.validateFilename(filename); @@ -753,7 +768,12 @@ export class FilesRouter { } try { const { filesController } = req.config; - const filename = FilesRouter._getFilenameFromParams(req); + const filename = filesController.normalizeFilename(FilesRouter._getFilenameFromParams(req)); + const filepathError = filesController.validateFilepath(filename); + if (filepathError) { + next(filepathError); + return; + } // run beforeDeleteFile trigger const file = new Parse.File(filename); file._url = await filesController.adapter.getFileLocation(req.config, filename); @@ -797,6 +817,13 @@ export class FilesRouter { FilesRouter._validateFileDownload(req, config); const { filesController } = config; let filename = FilesRouter._getFilenameFromParams(req); + filename = filesController.normalizeFilename(filename); + const filepathError = filesController.validateFilepath(filename); + if (filepathError) { + res.status(400); + res.json({ code: filepathError.code, error: filepathError.message }); + return; + } const file = new Parse.File(filename, { base64: '' }); const fileAuth = req.auth; const triggerResult = await triggers.maybeRunFileTrigger( @@ -806,7 +833,13 @@ export class FilesRouter { fileAuth ); if (triggerResult?.file?._name) { - filename = triggerResult.file._name; + filename = filesController.normalizeFilename(triggerResult.file._name); + const renamedPathError = filesController.validateFilepath(filename); + if (renamedPathError) { + res.status(400); + res.json({ code: renamedPathError.code, error: renamedPathError.message }); + return; + } } const data = await filesController.getMetadata(filename).catch(() => { res.status(200);