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
165 changes: 165 additions & 0 deletions spec/ParseFile.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,171 @@ 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('default should allow a valid custom content type the mime package does not recognize', async () => {
await reconfigureServer({
fileUpload: {
enableForPublic: true,
},
});
// A well-formed `type/subtype` that `mime` does not recognize (e.g. a
// vendor type) must still be accepted; only malformed or blocked
// Content-Types are rejected.
await expectAsync(
request({
method: 'POST',
url: 'http://localhost:8378/1/files/note.foo',
body: JSON.stringify({
_ApplicationId: 'test',
_JavaScriptKey: 'test',
_ContentType: 'application/vnd.api+json',
base64: Buffer.from('{}').toString('base64'),
}),
}).catch(e => {
throw new Error(e.data.error);
})
).toBeResolved();
});

it('default should block a malformed content type with invalid token characters', async () => {
await reconfigureServer({
fileUpload: {
enableForPublic: true,
},
});
const htmlContent = Buffer.from('<!DOCTYPE html><script>alert(1)</script>').toString(
'base64'
);
// Non-empty but malformed media types (extra slash, comma-separated values,
// whitespace) are not valid `type/subtype` tokens (RFC 9110 §5.6.2) and are
// sniffed by browsers, so they must be rejected too.
for (const contentType of ['image//svg+xml', 'text/plain,text/html', 'image/sv g']) {
await expectAsync(
request({
method: 'POST',
url: 'http://localhost:8378/1/files/note.foo',
body: JSON.stringify({
_ApplicationId: 'test',
_JavaScriptKey: 'test',
_ContentType: contentType,
base64: htmlContent,
}),
}).catch(e => {
throw new Error(e.data.error);
})
).toBeRejectedWith(
new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.')
);
}
});

it('works with a period in the file name', async () => {
await reconfigureServer({
fileUpload: {
Expand Down
2 changes: 1 addition & 1 deletion spec/PurchaseValidation.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ function createProduct() {
{
base64: new Buffer('download_file', 'utf-8').toString('base64'),
},
'text'
'text/plain'
);
return file.save().then(function () {
const product = new Parse.Object('_Product');
Expand Down
67 changes: 46 additions & 21 deletions src/Routers/FilesRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -437,32 +437,57 @@ 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, so the
// type the file is actually served as must be validated. Skip this when
// 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() : '';
// A valid media type is `type/subtype` where both are non-empty `token`s
// (RFC 9110 §5.6.2). Reject anything else.
const token = /^[!#$%&'*+\-.^_`|~A-Za-z0-9]+$/;
if (!token.test(type) || !token.test(subtype)) {
// A Content-Type that does not parse as `type/subtype` with valid,
// non-empty type AND subtype tokens is malformed: there is no valid MIME
// type without a subtype (RFC 9110 §8.3.1), and malformed tokens such as
// `image//svg+xml` or `text/plain,text/html` are equally unparseable.
// Browsers cannot parse such values 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 (e.g. `image`,
// `image/`). Surface the precise blocklist message when the bare token
// names a blocked extension (e.g. a no-slash `svg`), otherwise reject the
// unparseable Content-Type.
const bareToken = (slashIndex < 0 ? contentType.split(';')[0] : type).replace(
/\s+/g,
''
);
if (bareToken && !isValidExtension(bareToken)) {
rejectExtension(bareToken);
return;
}
next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid Content-Type.'));
return;
}
// Validate the well-formed Content-Type subtype against the blocklist, e.g.
// "image/svg+xml" -> "svg+xml", "image/svg+xml;charset=utf-8" -> "svg+xml".
// Valid custom/vendor types (e.g. "application/vnd.api+json") parse and are
// allowed; only blocked subtypes are rejected.
const contentTypeExtension = subtype.replace(/\s+/g, '');
if (!isValidExtension(contentTypeExtension)) {
rejectExtension(contentTypeExtension);
return;
}
}
}

Expand Down
Loading