Skip to content

Commit fc117ef

Browse files
authored
feat: Add server option fileDownload to restrict file download (parse-community#10394)
1 parent 89dec6d commit fc117ef

File tree

8 files changed

+447
-42
lines changed

8 files changed

+447
-42
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ The following table lists all route groups covered by `routeAllowList` with exam
392392
| Purchase validation | `validate_purchase` | `validate_purchase` |
393393

394394
> [!NOTE]
395-
> File upload, file download, and file metadata routes are not covered by `routeAllowList`. File upload access is controlled via the `fileUpload` option.
395+
> File routes are not covered by `routeAllowList`. File upload access is controlled via the `fileUpload` option. File download and metadata access is controlled via the `fileDownload` option.
396396
397397
## Email Verification and Password Reset
398398

resources/buildConfigDefinitions.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const parsers = require('../src/Options/parsers');
1515
const nestedOptionTypes = [
1616
'CustomPagesOptions',
1717
'DatabaseOptions',
18+
'FileDownloadOptions',
1819
'FileUploadOptions',
1920
'IdempotencyOptions',
2021
'Object',
@@ -34,6 +35,7 @@ const nestedOptionEnvPrefix = {
3435
DatabaseOptionsClientMetadata: 'PARSE_SERVER_DATABASE_CLIENT_METADATA_',
3536
CustomPagesOptions: 'PARSE_SERVER_CUSTOM_PAGES_',
3637
DatabaseOptions: 'PARSE_SERVER_DATABASE_',
38+
FileDownloadOptions: 'PARSE_SERVER_FILE_DOWNLOAD_',
3739
FileUploadOptions: 'PARSE_SERVER_FILE_UPLOAD_',
3840
IdempotencyOptions: 'PARSE_SERVER_EXPERIMENTAL_IDEMPOTENCY_',
3941
LiveQueryOptions: 'PARSE_SERVER_LIVEQUERY_',

spec/FileDownload.spec.js

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
'use strict';
2+
3+
describe('fileDownload', () => {
4+
describe('config validation', () => {
5+
it('should default all flags to true when fileDownload is undefined', async () => {
6+
await reconfigureServer({ fileDownload: undefined });
7+
const Config = require('../lib/Config');
8+
const config = Config.get(Parse.applicationId);
9+
expect(config.fileDownload.enableForAnonymousUser).toBe(true);
10+
expect(config.fileDownload.enableForAuthenticatedUser).toBe(true);
11+
expect(config.fileDownload.enableForPublic).toBe(true);
12+
});
13+
14+
it('should accept valid boolean values', async () => {
15+
await reconfigureServer({
16+
fileDownload: {
17+
enableForAnonymousUser: false,
18+
enableForAuthenticatedUser: false,
19+
enableForPublic: false,
20+
},
21+
});
22+
const Config = require('../lib/Config');
23+
const config = Config.get(Parse.applicationId);
24+
expect(config.fileDownload.enableForAnonymousUser).toBe(false);
25+
expect(config.fileDownload.enableForAuthenticatedUser).toBe(false);
26+
expect(config.fileDownload.enableForPublic).toBe(false);
27+
});
28+
29+
it('should reject non-object values', async () => {
30+
for (const value of ['string', 123, true, []]) {
31+
await expectAsync(reconfigureServer({ fileDownload: value })).toBeRejected();
32+
}
33+
});
34+
35+
it('should reject non-boolean flag values', async () => {
36+
await expectAsync(
37+
reconfigureServer({ fileDownload: { enableForAnonymousUser: 'yes' } })
38+
).toBeRejected();
39+
await expectAsync(
40+
reconfigureServer({ fileDownload: { enableForAuthenticatedUser: 1 } })
41+
).toBeRejected();
42+
await expectAsync(
43+
reconfigureServer({ fileDownload: { enableForPublic: null } })
44+
).toBeRejected();
45+
});
46+
});
47+
48+
describe('permissions', () => {
49+
async function uploadTestFile() {
50+
const request = require('../lib/request');
51+
const res = await request({
52+
headers: {
53+
'Content-Type': 'text/plain',
54+
'X-Parse-Application-Id': 'test',
55+
'X-Parse-Master-Key': 'test',
56+
},
57+
method: 'POST',
58+
url: 'http://localhost:8378/1/files/test.txt',
59+
body: 'hello world',
60+
});
61+
return res.data;
62+
}
63+
64+
it('should allow public download by default', async () => {
65+
await reconfigureServer();
66+
const file = await uploadTestFile();
67+
const request = require('../lib/request');
68+
const res = await request({
69+
method: 'GET',
70+
url: file.url,
71+
});
72+
expect(res.status).toBe(200);
73+
});
74+
75+
it('should block public download when enableForPublic is false', async () => {
76+
await reconfigureServer({
77+
fileDownload: { enableForPublic: false },
78+
});
79+
const file = await uploadTestFile();
80+
const request = require('../lib/request');
81+
try {
82+
await request({
83+
method: 'GET',
84+
url: file.url,
85+
});
86+
fail('should have thrown');
87+
} catch (e) {
88+
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
89+
}
90+
});
91+
92+
it('should allow authenticated user download when enableForAuthenticatedUser is true', async () => {
93+
await reconfigureServer({
94+
fileDownload: { enableForPublic: false, enableForAuthenticatedUser: true },
95+
});
96+
const file = await uploadTestFile();
97+
const user = new Parse.User();
98+
user.set('username', 'testuser');
99+
user.set('password', 'testpass');
100+
await user.signUp();
101+
const request = require('../lib/request');
102+
const res = await request({
103+
headers: {
104+
'X-Parse-Session-Token': user.getSessionToken(),
105+
},
106+
method: 'GET',
107+
url: file.url,
108+
});
109+
expect(res.status).toBe(200);
110+
});
111+
112+
it('should block authenticated user download when enableForAuthenticatedUser is false', async () => {
113+
await reconfigureServer({
114+
fileDownload: { enableForAuthenticatedUser: false },
115+
});
116+
const file = await uploadTestFile();
117+
const user = new Parse.User();
118+
user.set('username', 'testuser');
119+
user.set('password', 'testpass');
120+
await user.signUp();
121+
const request = require('../lib/request');
122+
try {
123+
await request({
124+
headers: {
125+
'X-Parse-Session-Token': user.getSessionToken(),
126+
},
127+
method: 'GET',
128+
url: file.url,
129+
});
130+
fail('should have thrown');
131+
} catch (e) {
132+
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
133+
}
134+
});
135+
136+
it('should block anonymous user download when enableForAnonymousUser is false', async () => {
137+
await reconfigureServer({
138+
fileDownload: { enableForAnonymousUser: false },
139+
});
140+
const file = await uploadTestFile();
141+
const user = await Parse.AnonymousUtils.logIn();
142+
const request = require('../lib/request');
143+
try {
144+
await request({
145+
headers: {
146+
'X-Parse-Session-Token': user.getSessionToken(),
147+
},
148+
method: 'GET',
149+
url: file.url,
150+
});
151+
fail('should have thrown');
152+
} catch (e) {
153+
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
154+
}
155+
});
156+
157+
it('should allow anonymous user download when enableForAnonymousUser is true', async () => {
158+
await reconfigureServer({
159+
fileDownload: { enableForAnonymousUser: true, enableForPublic: false },
160+
});
161+
const file = await uploadTestFile();
162+
const user = await Parse.AnonymousUtils.logIn();
163+
const request = require('../lib/request');
164+
const res = await request({
165+
headers: {
166+
'X-Parse-Session-Token': user.getSessionToken(),
167+
},
168+
method: 'GET',
169+
url: file.url,
170+
});
171+
expect(res.status).toBe(200);
172+
});
173+
174+
it('should allow master key to bypass all restrictions', async () => {
175+
await reconfigureServer({
176+
fileDownload: {
177+
enableForAnonymousUser: false,
178+
enableForAuthenticatedUser: false,
179+
enableForPublic: false,
180+
},
181+
});
182+
const file = await uploadTestFile();
183+
const request = require('../lib/request');
184+
const res = await request({
185+
headers: {
186+
'X-Parse-Master-Key': 'test',
187+
},
188+
method: 'GET',
189+
url: file.url,
190+
});
191+
expect(res.status).toBe(200);
192+
});
193+
194+
it('should block metadata endpoint when download is disabled for public', async () => {
195+
await reconfigureServer({
196+
fileDownload: { enableForPublic: false },
197+
});
198+
const file = await uploadTestFile();
199+
const request = require('../lib/request');
200+
// The file URL is like http://localhost:8378/1/files/test/abc_test.txt
201+
// The metadata URL replaces /files/APPID/ with /files/APPID/metadata/
202+
const url = new URL(file.url);
203+
const pathParts = url.pathname.split('/');
204+
// pathParts: ['', '1', 'files', 'test', 'abc_test.txt']
205+
const appIdIndex = pathParts.indexOf('files') + 1;
206+
pathParts.splice(appIdIndex + 1, 0, 'metadata');
207+
url.pathname = pathParts.join('/');
208+
try {
209+
await request({
210+
method: 'GET',
211+
url: url.toString(),
212+
});
213+
fail('should have thrown');
214+
} catch (e) {
215+
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
216+
}
217+
});
218+
219+
it('should block all downloads when all flags are false', async () => {
220+
await reconfigureServer({
221+
fileDownload: {
222+
enableForAnonymousUser: false,
223+
enableForAuthenticatedUser: false,
224+
enableForPublic: false,
225+
},
226+
});
227+
const file = await uploadTestFile();
228+
const request = require('../lib/request');
229+
try {
230+
await request({
231+
method: 'GET',
232+
url: file.url,
233+
});
234+
fail('should have thrown');
235+
} catch (e) {
236+
expect(e.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
237+
}
238+
});
239+
240+
it('should allow maintenance key to bypass download restrictions', async () => {
241+
await reconfigureServer({
242+
fileDownload: {
243+
enableForAnonymousUser: false,
244+
enableForAuthenticatedUser: false,
245+
enableForPublic: false,
246+
},
247+
});
248+
const file = await uploadTestFile();
249+
const request = require('../lib/request');
250+
const res = await request({
251+
headers: {
252+
'X-Parse-Maintenance-Key': 'testing',
253+
},
254+
method: 'GET',
255+
url: file.url,
256+
});
257+
expect(res.status).toBe(200);
258+
});
259+
260+
it('should allow maintenance key to bypass upload restrictions', async () => {
261+
await reconfigureServer({
262+
fileUpload: {
263+
enableForAnonymousUser: false,
264+
enableForAuthenticatedUser: false,
265+
enableForPublic: false,
266+
},
267+
});
268+
const request = require('../lib/request');
269+
const res = await request({
270+
headers: {
271+
'Content-Type': 'text/plain',
272+
'X-Parse-Application-Id': 'test',
273+
'X-Parse-Maintenance-Key': 'testing',
274+
},
275+
method: 'POST',
276+
url: 'http://localhost:8378/1/files/test.txt',
277+
body: 'hello world',
278+
});
279+
expect(res.data.url).toBeDefined();
280+
});
281+
});
282+
});

src/Config.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { version } from '../package.json';
1212
import {
1313
AccountLockoutOptions,
1414
DatabaseOptions,
15+
FileDownloadOptions,
1516
FileUploadOptions,
1617
IdempotencyOptions,
1718
LiveQueryOptions,
@@ -130,6 +131,7 @@ export class Config {
130131
allowHeaders,
131132
idempotencyOptions,
132133
fileUpload,
134+
fileDownload,
133135
pages,
134136
security,
135137
enforcePrivateUsers,
@@ -157,6 +159,11 @@ export class Config {
157159
this.validateAccountLockoutPolicy(accountLockout);
158160
this.validatePasswordPolicy(passwordPolicy);
159161
this.validateFileUploadOptions(fileUpload);
162+
if (fileDownload == null) {
163+
fileDownload = {};
164+
arguments[0].fileDownload = fileDownload;
165+
}
166+
this.validateFileDownloadOptions(fileDownload);
160167

161168
if (typeof revokeSessionOnPasswordReset !== 'boolean') {
162169
throw 'revokeSessionOnPasswordReset must be a boolean value';
@@ -586,6 +593,34 @@ export class Config {
586593
}
587594
}
588595

596+
static validateFileDownloadOptions(fileDownload) {
597+
try {
598+
if (fileDownload == null || typeof fileDownload !== 'object' || Array.isArray(fileDownload)) {
599+
throw 'fileDownload must be an object value.';
600+
}
601+
} catch (e) {
602+
if (e instanceof ReferenceError) {
603+
return;
604+
}
605+
throw e;
606+
}
607+
if (fileDownload.enableForAnonymousUser === undefined) {
608+
fileDownload.enableForAnonymousUser = FileDownloadOptions.enableForAnonymousUser.default;
609+
} else if (typeof fileDownload.enableForAnonymousUser !== 'boolean') {
610+
throw 'fileDownload.enableForAnonymousUser must be a boolean value.';
611+
}
612+
if (fileDownload.enableForPublic === undefined) {
613+
fileDownload.enableForPublic = FileDownloadOptions.enableForPublic.default;
614+
} else if (typeof fileDownload.enableForPublic !== 'boolean') {
615+
throw 'fileDownload.enableForPublic must be a boolean value.';
616+
}
617+
if (fileDownload.enableForAuthenticatedUser === undefined) {
618+
fileDownload.enableForAuthenticatedUser = FileDownloadOptions.enableForAuthenticatedUser.default;
619+
} else if (typeof fileDownload.enableForAuthenticatedUser !== 'boolean') {
620+
throw 'fileDownload.enableForAuthenticatedUser must be a boolean value.';
621+
}
622+
}
623+
589624
static validateIps(field, masterKeyIps) {
590625
for (let ip of masterKeyIps) {
591626
if (ip.includes('/')) {

0 commit comments

Comments
 (0)