Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 20 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
195 changes: 195 additions & 0 deletions spec/FileNameNormalization.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
'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 invalid filepaths on download and delete routes', async () => {
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
await reconfigureServer({
filesAdapter: gfsAdapter,
preserveFileName: true,
});

for (const method of ['GET', 'DELETE']) {
try {
await request({
method,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
url:
method === 'GET'
? 'http://localhost:8378/1/files/test/foo%2F..%2Fbar'
: 'http://localhost:8378/1/files/foo%2F..%2Fbar',
});
fail(`should have rejected invalid filepath for ${method}`);
} catch (error) {
expect(error.status).toBe(400);
expect(error.data.code).toBe(Parse.Error.INVALID_FILE_NAME);
}
}
});

it('rejects reserved filepath segments on 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%2Fevil.txt',
});
fail('should have rejected reserved filepath segment');
} catch (error) {
expect(error.status).toBe(400);
expect(error.data.code).toBe(Parse.Error.INVALID_FILE_NAME);
expect(error.data.error).toContain('reserved segment');
}
});

it('rejects invalid filepath renamed by beforeFind on download and metadata routes', async () => {
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
await reconfigureServer({
filesAdapter: gfsAdapter,
preserveFileName: true,
});

const file = new Parse.File('good.txt', [1, 2, 3], 'text/plain');
await file.save({ useMasterKey: true });
Parse.Cloud.beforeFind(Parse.File, req => {
req.file._name = '../evil.txt';
return { file: req.file };
});

for (const url of [file.url(), `http://localhost:8378/1/files/test/metadata/${file._name}`]) {
try {
await request({
url,
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
},
});
fail(`should have rejected renamed filepath for ${url}`);
} catch (error) {
expect(error.status).toBe(400);
expect(error.data.code).toBe(Parse.Error.INVALID_FILE_NAME);
}
}
});

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);
}
});
});
66 changes: 65 additions & 1 deletion spec/FilesController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ const GridFSBucketAdapter = require('../lib/Adapters/Files/GridFSBucketAdapter')
.GridFSBucketAdapter;
const Config = require('../lib/Config');
const FilesController = require('../lib/Controllers/FilesController').default;
const {
normalizeFilename,
validateFilename,
validateFilepath,
} = require('../lib/Adapters/Files/FilesAdapter');
const databaseURI = 'mongodb://localhost:27017/parse';

const mockAdapter = {
Expand Down Expand Up @@ -151,7 +156,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);
Expand Down Expand Up @@ -218,4 +223,63 @@ 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);
}
});

it('returns non-string filenames unchanged from normalizeFilename', () => {
expect(normalizeFilename(null)).toBeNull();
expect(normalizeFilename(42)).toBe(42);
});

it('rejects reserved filepath segments and invalid filename segments', () => {
const reservedError = validateFilepath('metadata/evil.txt');
expect(reservedError).not.toBeNull();
expect(reservedError.message).toContain('reserved segment');

const tooLongError = validateFilename(`_${'a'.repeat(128)}`);
expect(tooLongError).not.toBeNull();
expect(tooLongError.message).toContain('too long');

const invalidCharsError = validateFilename('bad?.txt');
expect(invalidCharsError).not.toBeNull();
expect(invalidCharsError.message).toContain('invalid characters');
});
});
Loading
Loading