CVE-2021-32683: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: wire-webapp is the web version of Wire, an open-source messenger. A cross-site scripting vulnerability exists in wire-webapp prior to version 2021-06-01-production.0. If a user is instructed to open an image in a new tab (right click -> open in new tab, or copy the URL and paste it in the URL bar), an the image payload is executed on the domain hosting the app (app.wire.com). In particular, if an image contains malicious code in addition to the actual picture, this code is executed on app.wire.com. This allows the attacker to fully control the user account. The vulnerability was patched in version 2021-06-01-production.0. As a workaround, users should not try to open image URLs.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2021-06-15T20:15Z
Repository: https://github.com/wireapp/wire-webapp
Language: TypeScript
Files changed: src/page/template/content/conversation/input-bar.htm, src/script/Config.ts, src/script/components/asset/imageAsset.ts, src/script/view_model/content/InputBarViewModel.ts, src/worker/image-worker.js
Commit date: 2021-05-07 14:04:40 +0200
Commit message: refactor: Remove jpeg-large support

Diff: diff --git a/src/page/template/content/conversation/input-bar.htm b/src/page/template/content/conversation/input-bar.htm
index a1440d7cd..f793372a3 100644
--- a/src/page/template/content/conversation/input-bar.htm
+++ b/src/page/template/content/conversation/input-bar.htm
@@ -99,11 +99,11 @@
             <!-- ko ifnot: input().length -->
               <message-timer-button params="conversation: conversationEntity"></message-timer-button>
 
               <label id="conversation-input-bar-photo" class="controls-right-button button-icon-large">
                 <image-icon></image-icon>
-                <input data-bind="attr: {accept: allowedImageTypes}, file_select: uploadImages" type="file" multiple="multiple" data-uie-name="do-share-image"/>
+                <input data-bind="attr: {accept: acceptedImageTypes}, file_select: uploadImages" type="file" multiple="multiple" data-uie-name="do-share-image"/>
               </label>
 
               <label id="conversation-input-bar-files" class="controls-right-button button-icon-large" data-bind="attr: {title: t('tooltipConversationFile')}">
                 <attachment-icon></attachment-icon>
                 <input data-bind="attr: {accept: allowedFileTypes}, file_select: uploadFiles" type="file" multiple="multiple" data-uie-name="do-share-file"/>
diff --git a/src/script/Config.ts b/src/script/Config.ts
index 08134318c..0936b739b 100644
--- a/src/script/Config.ts
+++ b/src/script/Config.ts
@@ -103,11 +103,12 @@ export class Configuration {
   readonly MINIMUM_PASSWORD_LENGTH = 8;
 
   /** measured in pixel */
   readonly SCROLL_TO_LAST_MESSAGE_THRESHOLD = 100;
 
-  readonly ALLOWED_IMAGE_TYPES = ['image/bmp', 'image/gif', 'image/jpeg', 'image/jpg', 'image/png', '.jpg-large'];
+  /** Image MIME types */
+  readonly ALLOWED_IMAGE_TYPES = ['image/bmp', 'image/gif', 'image/jpeg', 'image/jpg', 'image/png'];
 }
 
 let instance: Configuration;
 
 const Config = {
diff --git a/src/script/components/asset/imageAsset.ts b/src/script/components/asset/imageAsset.ts
index ed4e87905..989d0acc7 100644
--- a/src/script/components/asset/imageAsset.ts
+++ b/src/script/components/asset/imageAsset.ts
@@ -22,10 +22,11 @@ import ko from 'knockout';
 import type {ContentMessage} from '../../entity/message/ContentMessage';
 import type {MediumImage} from '../../entity/message/MediumImage';
 import {viewportObserver} from '../../ui/viewportObserver';
 import {AbstractAssetTransferStateTracker} from './AbstractAssetTransferStateTracker';
 import './AssetLoader';
+import {Config} from '../../Config';
 
 interface Params {
   asset: MediumImage;
   message: ContentMessage;
   onClick: (message: ContentMessage, event: MouseEvent) => void;
@@ -58,12 +59,20 @@ class ImageAssetComponent extends AbstractAssetTransferStateTracker {
     ko.computed(
       () => {
         if (this.isVisible() && asset.resource()) {
           this.assetRepository
             .load(asset.resource())
-            .then(blob => {
-              this.imageUrl(window.URL.createObjectURL(blob));
+            .then((blob: Blob) => {
+              const allowedImageTypes = [
+                'application/octet-stream', // Octet-stream is required to paste images from clipboard
+                ...Config.getConfig().ALLOWED_IMAGE_TYPES,
+              ];
+              if (allowedImageTypes.includes(blob.type)) {
+                this.imageUrl(window.URL.createObjectURL(blob));
+              } else {
+                throw new Error(`Unsupported image type "${blob.type}".`);
+              }
             })
             .catch(error => console.error(error));
         }
       },
       {disposeWhenNodeIsRemoved: element},
diff --git a/src/script/view_model/content/InputBarViewModel.ts b/src/script/view_model/content/InputBarViewModel.ts
index 8735135b4..473bffe57 100644
--- a/src/script/view_model/content/InputBarViewModel.ts
+++ b/src/script/view_model/content/InputBarViewModel.ts
@@ -102,11 +102,12 @@ export class InputBarViewModel {
   readonly pingTooltip: string;
   readonly hasLocalEphemeralTimer: ko.PureComputed<boolean>;
   readonly renderMessage: typeof renderMessage;
   readonly input: ko.Observable<string>;
   private readonly showAvailabilityTooltip: ko.PureComputed<boolean>;
-  readonly allowedImageTypes: string;
+  /** MIME types and file extensions are accepted */
+  readonly acceptedImageTypes: string;
   readonly allowedFileTypes: string;
 
   static get CONFIG() {
     return {
       ASSETS: {
@@ -128,11 +129,11 @@ export class InputBarViewModel {
     private readonly userState = container.resolve(UserState),
     private readonly conversationState = container.resolve(ConversationState),
   ) {
     this.shadowInput = null;
     this.textarea = null;
-    this.allowedImageTypes = Config.getConfig().ALLOWED_IMAGE_TYPES.join(',');
+    this.acceptedImageTypes = Config.getConfig().ALLOWED_IMAGE_TYPES.join(',');
     this.allowedFileTypes = Config.getConfig().FEATURE.ALLOWED_FILE_UPLOAD_EXTENSIONS.join(',');
 
     this.selectionStart = ko.observable(0);
     this.selectionEnd = ko.observable(0);
 
diff --git a/src/worker/image-worker.js b/src/worker/image-worker.js
index f7a228d33..b24a0f3d7 100644
--- a/src/worker/image-worker.js
+++ b/src/worker/image-worker.js
@@ -31,10 +31,11 @@ self.addEventListener('message', (/** @type {MessageEvent<Data>} */ event) => {
   if (event.data.useProfileImageSize) {
     MAX_SIZE = 280;
     MAX_FILE_SIZE = 1024 * 1024;
   }
 
+  // Unfortunately, Jimp doesn't support MIME type "image/webp": https://github.com/oliver-moran/jimp/issues/144
   Jimp.read(event.data.buffer).then(image => {
     if (event.data.useProfileImageSize) {
       image.cover(MAX_SIZE, MAX_SIZE);
     } else if (image.bitmap.width > MAX_SIZE || image.bitmap.height > MAX_SIZE) {
       image.scaleToFit(MAX_SIZE, MAX_SIZE);

Vulnerable code:                 <input data-bind="attr: {accept: allowedImageTypes}, file_select: uploadImages" type="file" multiple="multiple" data-uie-name="do-share-image"/>
  readonly ALLOWED_IMAGE_TYPES = ['image/bmp', 'image/gif', 'image/jpeg', 'image/jpg', 'image/png', '.jpg-large'];
            .then(blob => {
              this.imageUrl(window.URL.createObjectURL(blob));
  readonly allowedImageTypes: string;
    this.allowedImageTypes = Config.getConfig().ALLOWED_IMAGE_TYPES.join(',');
Fixed code:                 <input data-bind="attr: {accept: acceptedImageTypes}, file_select: uploadImages" type="file" multiple="multiple" data-uie-name="do-share-image"/>
  /** Image MIME types */
  readonly ALLOWED_IMAGE_TYPES = ['image/bmp', 'image/gif', 'image/jpeg', 'image/jpg', 'image/png'];
import {Config} from '../../Config';
            .then((blob: Blob) => {
              const allowedImageTypes = [
                'application/octet-stream', // Octet-stream is required to paste images from clipboard
                ...Config.getConfig().ALLOWED_IMAGE_TYPES,
              ];
              if (allowedImageTypes.includes(blob.type)) {
                this.imageUrl(window.URL.createObjectURL(blob));
              } else {
                throw new Error(`Unsupported image type "${blob.type}".`);
              }
  /** MIME types and file extensions are accepted */
  readonly acceptedImageTypes: string;
    this.acceptedImageTypes = Config.getConfig().ALLOWED_IMAGE_TYPES.join(',');
  // Unfortunately, Jimp doesn't support MIME type "image/webp": https://github.com/oliver-moran/jimp/issues/144