Skip to content

Commit dfdaae4

Browse files
committed
feat: more tests
1 parent d2d2651 commit dfdaae4

3 files changed

Lines changed: 216 additions & 0 deletions

File tree

spec/FileNameNormalization.spec.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,85 @@ describe_only_db('mongo')('Unicode filename normalization', () => {
9595
expect(documents.length).toBe(0);
9696
});
9797

98+
it('rejects invalid filepaths on download and delete routes', async () => {
99+
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
100+
await reconfigureServer({
101+
filesAdapter: gfsAdapter,
102+
preserveFileName: true,
103+
});
104+
105+
for (const method of ['GET', 'DELETE']) {
106+
try {
107+
await request({
108+
method,
109+
headers: {
110+
'X-Parse-Application-Id': 'test',
111+
'X-Parse-Master-Key': 'test',
112+
},
113+
url:
114+
method === 'GET'
115+
? 'http://localhost:8378/1/files/test/foo%2F..%2Fbar'
116+
: 'http://localhost:8378/1/files/foo%2F..%2Fbar',
117+
});
118+
fail(`should have rejected invalid filepath for ${method}`);
119+
} catch (error) {
120+
expect(error.status).toBe(400);
121+
expect(error.data.code).toBe(Parse.Error.INVALID_FILE_NAME);
122+
}
123+
}
124+
});
125+
126+
it('rejects reserved filepath segments on download routes', async () => {
127+
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
128+
await reconfigureServer({
129+
filesAdapter: gfsAdapter,
130+
preserveFileName: true,
131+
});
132+
133+
try {
134+
await request({
135+
method: 'GET',
136+
url: 'http://localhost:8378/1/files/test/metadata%2Fevil.txt',
137+
});
138+
fail('should have rejected reserved filepath segment');
139+
} catch (error) {
140+
expect(error.status).toBe(400);
141+
expect(error.data.code).toBe(Parse.Error.INVALID_FILE_NAME);
142+
expect(error.data.error).toContain('reserved segment');
143+
}
144+
});
145+
146+
it('rejects invalid filepath renamed by beforeFind on download and metadata routes', async () => {
147+
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
148+
await reconfigureServer({
149+
filesAdapter: gfsAdapter,
150+
preserveFileName: true,
151+
});
152+
153+
const file = new Parse.File('good.txt', [1, 2, 3], 'text/plain');
154+
await file.save({ useMasterKey: true });
155+
Parse.Cloud.beforeFind(Parse.File, req => {
156+
req.file._name = '../evil.txt';
157+
return { file: req.file };
158+
});
159+
160+
for (const url of [file.url(), `http://localhost:8378/1/files/test/metadata/${file._name}`]) {
161+
try {
162+
await request({
163+
url,
164+
headers: {
165+
'X-Parse-Application-Id': 'test',
166+
'X-Parse-Master-Key': 'test',
167+
},
168+
});
169+
fail(`should have rejected renamed filepath for ${url}`);
170+
} catch (error) {
171+
expect(error.status).toBe(400);
172+
expect(error.data.code).toBe(Parse.Error.INVALID_FILE_NAME);
173+
}
174+
}
175+
});
176+
98177
it('rejects path traversal in metadata download routes', async () => {
99178
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
100179
await reconfigureServer({

spec/FilesController.spec.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const GridFSBucketAdapter = require('../lib/Adapters/Files/GridFSBucketAdapter')
66
const Config = require('../lib/Config');
77
const FilesController = require('../lib/Controllers/FilesController').default;
88
const {
9+
normalizeFilename,
910
validateFilename,
1011
validateFilepath,
1112
} = require('../lib/Adapters/Files/FilesAdapter');
@@ -262,4 +263,23 @@ describe('FilesController', () => {
262263
expect(validateFilepath(bad).code).toBe(Parse.Error.INVALID_FILE_NAME);
263264
}
264265
});
266+
267+
it('returns non-string filenames unchanged from normalizeFilename', () => {
268+
expect(normalizeFilename(null)).toBeNull();
269+
expect(normalizeFilename(42)).toBe(42);
270+
});
271+
272+
it('rejects reserved filepath segments and invalid filename segments', () => {
273+
const reservedError = validateFilepath('metadata/evil.txt');
274+
expect(reservedError).not.toBeNull();
275+
expect(reservedError.message).toContain('reserved segment');
276+
277+
const tooLongError = validateFilename(`_${'a'.repeat(128)}`);
278+
expect(tooLongError).not.toBeNull();
279+
expect(tooLongError.message).toContain('too long');
280+
281+
const invalidCharsError = validateFilename('bad?.txt');
282+
expect(invalidCharsError).not.toBeNull();
283+
expect(invalidCharsError.message).toContain('invalid characters');
284+
});
265285
});

spec/graphqlUpload.spec.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
'use strict';
2+
3+
const http = require('http');
4+
const { createGraphQLUploadMiddleware } = require('../lib/GraphQL/helpers/graphqlUpload');
5+
const { handleUpload } = require('../lib/GraphQL/loaders/filesMutations');
6+
7+
const createMockReadStream = () => {
8+
const stream = {
9+
pipe(destination) {
10+
setImmediate(() => {
11+
if (stream.endHandler) {
12+
stream.endHandler();
13+
}
14+
});
15+
return destination;
16+
},
17+
on(event, handler) {
18+
if (event === 'end') {
19+
stream.endHandler = handler;
20+
}
21+
},
22+
destroy() {},
23+
};
24+
return stream;
25+
};
26+
27+
const createMockHttpResponse = (statusCode, body) => ({
28+
statusCode,
29+
on(event, handler) {
30+
if (event === 'data') {
31+
handler(body);
32+
}
33+
if (event === 'end') {
34+
handler();
35+
}
36+
},
37+
});
38+
39+
describe('graphqlUpload helper', () => {
40+
it('skips middleware for non-multipart requests', async () => {
41+
const middleware = createGraphQLUploadMiddleware({ maxFileSize: 1000 });
42+
const req = { is: type => type !== 'multipart/form-data' };
43+
const next = jasmine.createSpy('next');
44+
45+
await middleware(req, {}, next);
46+
47+
expect(next).toHaveBeenCalledWith();
48+
expect(req.body).toBeUndefined();
49+
});
50+
51+
it('forwards multipart parsing errors to Express', async () => {
52+
const middleware = createGraphQLUploadMiddleware({ maxFileSize: 1000 });
53+
const req = {
54+
is: () => true,
55+
headers: { 'content-type': 'multipart/form-data; boundary=----parse' },
56+
};
57+
const next = jasmine.createSpy('next');
58+
59+
await middleware(req, {}, next);
60+
61+
expect(next).toHaveBeenCalledWith(jasmine.any(Error));
62+
});
63+
});
64+
65+
describe('filesMutations handleUpload', () => {
66+
const upload = {
67+
createReadStream: createMockReadStream,
68+
filename: 'café.txt',
69+
mimetype: 'text/plain',
70+
};
71+
const config = {
72+
serverURL: 'http://localhost:8378/1',
73+
headers: {},
74+
};
75+
76+
afterEach(() => {
77+
if (jasmine.isSpy(http.request)) {
78+
http.request.and.callThrough();
79+
}
80+
});
81+
82+
it('rejects non-2xx responses from the internal /files handoff', async () => {
83+
spyOn(http, 'request').and.callFake((_options, callback) => {
84+
const req = { on() {}, end() {} };
85+
setImmediate(() => {
86+
callback(createMockHttpResponse(400, JSON.stringify({ code: 130, error: 'bad upload' })));
87+
});
88+
return req;
89+
});
90+
91+
await expectAsync(
92+
handleUpload(Promise.resolve(upload), config)
93+
).toBeRejectedWith(jasmine.objectContaining({ code: 130 }));
94+
});
95+
96+
it('rejects invalid JSON responses from the internal /files handoff', async () => {
97+
spyOn(http, 'request').and.callFake((_options, callback) => {
98+
const req = { on() {}, end() {} };
99+
setImmediate(() => {
100+
callback(createMockHttpResponse(200, 'not-json'));
101+
});
102+
return req;
103+
});
104+
105+
await expectAsync(
106+
handleUpload(Promise.resolve(upload), config)
107+
).toBeRejectedWith(jasmine.objectContaining({ code: Parse.Error.FILE_SAVE_ERROR }));
108+
});
109+
110+
it('wraps request failures in FILE_SAVE_ERROR', async () => {
111+
spyOn(http, 'request').and.throwError('connection failed');
112+
113+
await expectAsync(
114+
handleUpload(Promise.resolve(upload), config)
115+
).toBeRejectedWith(jasmine.objectContaining({ code: Parse.Error.FILE_SAVE_ERROR }));
116+
});
117+
});

0 commit comments

Comments
 (0)