From d0707e3b2b78b3a2a4e239108a72b3def7b0a37e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6=20=28skjnldsv=29?= Date: Thu, 12 Mar 2020 09:56:27 +0100 Subject: [PATCH 1/2] Properly sanitize avatars in upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: John Molakvoæ (skjnldsv) --- package-lock.json | 13 ++- package.json | 1 + .../ContactDetails/ContactDetailsAvatar.vue | 86 +++++++++++++++++-- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index ddf26a424..cb73e0453 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1204,6 +1204,11 @@ "to-fast-properties": "^2.0.0" } }, + "@mattkrick/sanitize-svg": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@mattkrick/sanitize-svg/-/sanitize-svg-0.2.1.tgz", + "integrity": "sha512-9T5xb8pq0GLNuKmKbXLvILOi1bQeu9FzAup+dB3zWRgzOVh40yE0YqWY/lrKzBrpj968ZaKTxegTwU1zyRtfBA==" + }, "@nextcloud/auth": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-1.2.1.tgz", @@ -1575,9 +1580,9 @@ "dev": true }, "acorn": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz", - "integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.1.tgz", + "integrity": "sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==", "dev": true }, "acorn-jsx": { @@ -4800,7 +4805,7 @@ "dependencies": { "minimist": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.1.3.tgz", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.1.3.tgz", "integrity": "sha1-O+39kaktOQFvz6ocaB6Pqhoe/ag=", "dev": true } diff --git a/package.json b/package.json index 519de6c2e..d773a8fd1 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "stylelint:fix": "stylelint src --fix" }, "dependencies": { + "@mattkrick/sanitize-svg": "^0.2.1", "@nextcloud/auth": "^1.2.1", "@nextcloud/dialogs": "^1.2.1", "@nextcloud/initial-state": "^1.1.0", diff --git a/src/components/ContactDetails/ContactDetailsAvatar.vue b/src/components/ContactDetails/ContactDetailsAvatar.vue index b5b2adaf4..206cb5062 100644 --- a/src/components/ContactDetails/ContactDetailsAvatar.vue +++ b/src/components/ContactDetails/ContactDetailsAvatar.vue @@ -91,6 +91,7 @@ import { ActionLink, ActionButton } from '@nextcloud/vue' import { getFilePickerBuilder } from '@nextcloud/dialogs' import { generateRemoteUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' +import sanitizeSVG from '@mattkrick/sanitize-svg' const axios = () => import('axios') @@ -147,22 +148,92 @@ export default { if (file && file.size && file.size <= 1 * 1024 * 1024) { const reader = new FileReader() const self = this + let type = '' - reader.onload = function(e) { - // only getting the raw binary base64 - self.setPhoto(reader.result.split(',').pop(), file.type) + reader.onloadend = async function(e) { + try { + // We got an ArrayBuffer, checking the true mime type... + if (typeof e.target.result === 'object') { + const uint = new Uint8Array(e.target.result) + const bytes = [] + uint.forEach((byte) => { + bytes.push(byte.toString(16)) + }) + const hex = bytes.join('').toUpperCase() + + if (self.getMimetype(hex).startsWith('image/')) { + type = self.getMimetype(hex) + // we got a valid image, read it again as base64 + reader.readAsDataURL(file) + return + } + throw new Error('Wrong image mimetype') + } + + // else we got the base64 and we're good to go! + const imageBase64 = e.target.result.split(',').pop() + + if (e.target.result.indexOf('image/svg') > -1) { + const imageSvg = atob(imageBase64) + const cleanSvg = await sanitizeSVG(imageSvg) + if (!cleanSvg) { + throw new Error('Unsafe svg image') + } + } + + // All is well! Set the photo + self.setPhoto(imageBase64, type) + } catch (error) { + console.error(error) + OC.Notification.showTemporary(t('contacts', 'Invalid image')) + } finally { + self.resetPicker() + } } - reader.readAsDataURL(file) + // start by reading the magic bytes to detect proper photo mimetype + const blob = file.slice(0, 4) + reader.readAsArrayBuffer(blob) } else { OC.Notification.showTemporary(t('contacts', 'Image is too big (max 1MB).')) - // reset input - event.target.value = '' - this.loading = false + this.resetPicker() } } }, + /** + * Reset image pciker input + */ + resetPicker() { + // reset input + this.$refs.uploadInput.value = '' + this.loading = false + }, + + /** + * Return the mimetype based on the first magix byte + * + * @param {string} signature the first 4 bytes + * @returns {string} the mimetype + */ + getMimetype(signature) { + switch (signature) { + case '89504E47': + return 'image/png' + case '47494638': + return 'image/gif' + case '3C3F786D': + case '3C737667': + return 'image/svg+xml' + case 'FFD8FFDB': + case 'FFD8FFE0': + case 'FFD8FFE1': + return 'image/jpeg' + default: + return 'Unknown filetype' + } + }, + /** * Update the contact photo * @@ -269,6 +340,7 @@ export default { this.updateHeightWidth(this.$refs.img.naturalHeight, this.$refs.img.naturalWidth) } }, + /** * Updates the current height and width data * based on the viewer maximum size From 1d9d5bf5dfa6b1c7090786c47f3c045b786e9d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6=20=28skjnldsv=29?= Date: Thu, 12 Mar 2020 10:08:38 +0100 Subject: [PATCH 2/2] use application/octet-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: John Molakvoæ (skjnldsv) --- src/components/ContactDetails/ContactDetailsAvatar.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ContactDetails/ContactDetailsAvatar.vue b/src/components/ContactDetails/ContactDetailsAvatar.vue index 206cb5062..e2a03b9bf 100644 --- a/src/components/ContactDetails/ContactDetailsAvatar.vue +++ b/src/components/ContactDetails/ContactDetailsAvatar.vue @@ -230,7 +230,7 @@ export default { case 'FFD8FFE1': return 'image/jpeg' default: - return 'Unknown filetype' + return 'application/octet-stream' } },