mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
fix: Stored XSS via malformed Content-Type bypassing file upload extension blocklist ([GHSA-r899-h629-j84r](https://github.com/parse-community/parse-server/security/advisories/GHSA-r899-h629-j84r)) (#10521)
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
|
||||
+46
-21
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user