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
108 changes: 108 additions & 0 deletions spec/ParseFile.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,114 @@ describe('Parse.File testing', () => {
).toBeResolved();
});

it('default should block a malformed content type with no slash', async () => {
await reconfigureServer({
fileUpload: {
enableForPublic: true,
},
});
const htmlContent = Buffer.from('<!DOCTYPE html><script>alert(1)</script>').toString(
'base64'
);
for (const filename of ['note.foo', 'data.bar']) {
await expectAsync(
request({
method: 'POST',
url: `http://localhost:8378/1/files/${filename}`,
body: JSON.stringify({
_ApplicationId: 'test',
_JavaScriptKey: 'test',
_ContentType: 'image',
base64: htmlContent,
}),
}).catch(e => {
throw new Error(e.data.error);
})
).toBeRejectedWith(
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
);
}
});

it('default should block a malformed content type with an empty subtype', async () => {
await reconfigureServer({
fileUpload: {
enableForPublic: true,
},
});
const htmlContent = Buffer.from('<!DOCTYPE html><script>alert(1)</script>').toString(
'base64'
);
for (const filename of ['note.foo', 'data.bar']) {
await expectAsync(
request({
method: 'POST',
url: `http://localhost:8378/1/files/${filename}`,
body: JSON.stringify({
_ApplicationId: 'test',
_JavaScriptKey: 'test',
_ContentType: 'image/',
base64: htmlContent,
}),
}).catch(e => {
throw new Error(e.data.error);
})
).toBeRejectedWith(
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
);
}
});

it('default should block a malformed content type when the filename has no extension', async () => {
await reconfigureServer({
fileUpload: {
enableForPublic: true,
},
});
const htmlContent = Buffer.from('<!DOCTYPE html><script>alert(1)</script>').toString(
'base64'
);
await expectAsync(
request({
method: 'POST',
url: 'http://localhost:8378/1/files/note',
body: JSON.stringify({
_ApplicationId: 'test',
_JavaScriptKey: 'test',
_ContentType: 'image',
base64: htmlContent,
}),
}).catch(e => {
throw new Error(e.data.error);
})
).toBeRejectedWith(
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
);
});

it('allows a malformed content type when all extensions are allowed', async () => {
await reconfigureServer({
fileUpload: {
enableForPublic: true,
fileExtensions: ['*'],
},
});
await expectAsync(
request({
method: 'POST',
url: 'http://localhost:8378/1/files/note.foo',
body: JSON.stringify({
_ApplicationId: 'test',
_JavaScriptKey: 'test',
_ContentType: 'image',
base64: 'ParseA==',
}),
}).catch(e => {
throw new Error(e.data.error);
})
).toBeResolved();
});

it('works with a period in the file name', async () => {
await reconfigureServer({
fileUpload: {
Expand Down
49 changes: 28 additions & 21 deletions src/Routers/FilesRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -437,32 +437,39 @@ export class FilesRouter {
let extension = Utils.getFileExtension(filename);
extension = extension?.split(';')[0]?.replace(/\s+/g, '');

// Derive the Content-Type subtype as a fallback identifier, e.g.
// "image/svg+xml" -> "svg+xml", "image/svg+xml;charset=utf-8" -> "svg+xml".
let contentTypeExtension;
if (contentType && contentType.includes('/')) {
contentTypeExtension = contentType.split('/')[1]?.split(';')[0]?.replace(/\s+/g, '');
} else if (contentType) {
// Malformed Content-Type without a slash: use the raw value so the
// existing rejection path still fires.
contentTypeExtension = contentType.split(';')[0]?.replace(/\s+/g, '');
}

// The blocklist must be evaluated against the type the file is actually
// served as. `FilesController.createFile` derives the stored Content-Type
// from the filename extension only when `mime` recognizes it; otherwise it
// preserves the client-supplied Content-Type. So the Content-Type subtype
// must also be validated whenever the filename has no usable extension OR
// an extension that `mime` does not recognize (e.g. "file.svg~"), which
// would otherwise slip past the exact-match blocklist.
const isExtensionRecognized = extension && mime.getType(filename);
if (extension && !isValidExtension(extension)) {
rejectExtension(extension);
return;
}
if (!isExtensionRecognized && contentTypeExtension && !isValidExtension(contentTypeExtension)) {
rejectExtension(contentTypeExtension);
return;

// When the filename extension is not recognized by `mime`,
// `FilesController.createFile` cannot derive a Content-Type from the
// filename and preserves the client-supplied Content-Type verbatim. The
// type the file is actually served as must therefore be validated against
// the blocklist. A Content-Type that does not parse as `type/subtype` with
// a non-empty type AND subtype (e.g. `image`, `image/`) is unparseable:
// browsers ignore it and fall back to MIME-sniffing the file body, which
// can render HTML/script markers as active content on storage adapters
// that serve the stored Content-Type. Reject such malformed values rather
// than store them verbatim, unless extension filtering is disabled (`*`).
const allowsAllExtensions = fileExtensions.includes('*');
if (!isExtensionRecognized && contentType && !allowsAllExtensions) {
const slashIndex = contentType.indexOf('/');
const type = slashIndex > 0 ? contentType.slice(0, slashIndex).trim() : '';
const subtype =
slashIndex > 0 ? contentType.slice(slashIndex + 1).split(';')[0].trim() : '';
if (!type || !subtype) {
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.'));
return;
}
// Validate the Content-Type subtype against the blocklist, e.g.
// "image/svg+xml" -> "svg+xml", "image/svg+xml;charset=utf-8" -> "svg+xml".
const contentTypeExtension = subtype.replace(/\s+/g, '');
if (!isValidExtension(contentTypeExtension)) {
rejectExtension(contentTypeExtension);
return;
}
}
}

Expand Down
Loading