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
116 changes: 116 additions & 0 deletions spec/FileNameNormalization.spec.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
46 changes: 45 additions & 1 deletion spec/FilesController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
});
});
22 changes: 22 additions & 0 deletions spec/ParseFile.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
65 changes: 65 additions & 0 deletions spec/ParseGraphQLServer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});

Expand Down
Loading
Loading