CVE-2021-3780: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: peertube is vulnerable to Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
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-09-15T12:15Z
Repository: https://github.com/chocobozzz/peertube
Language: TypeScript
Files changed: client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts, client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.ts, client/src/app/shared/shared-actor-image/actor-avatar.component.ts, client/src/app/shared/shared-forms/preview-upload.component.ts, client/src/root-helpers/images.ts, client/src/root-helpers/index.ts
Commit date: 2021-09-08 10:16:39 +0200
Commit message: Safer image preview

Diff: diff --git a/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts b/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts
index 2c0e45e20..8b7d64ed3 100644
--- a/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts
+++ b/client/src/app/shared/shared-actor-image-edit/actor-avatar-edit.component.ts
@@ -1,11 +1,11 @@
 import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
 import { Notifier, ServerService } from '@app/core'
 import { Account, VideoChannel } from '@app/shared/shared-main'
 import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
 import { getBytes } from '@root-helpers/bytes'
+import { imageToDataURL } from '@root-helpers/images'
 
 @Component({
   selector: 'my-actor-avatar-edit',
   templateUrl: './actor-avatar-edit.component.html',
   styleUrls: [
@@ -28,14 +28,13 @@ export class ActorAvatarEditComponent implements OnInit {
 
   avatarFormat = ''
   maxAvatarSize = 0
   avatarExtensions = ''
 
-  preview: SafeResourceUrl
+  preview: string
 
   constructor (
-    private sanitizer: DomSanitizer,
     private serverService: ServerService,
     private notifier: Notifier
   ) { }
 
   ngOnInit (): void {
@@ -61,11 +60,11 @@ export class ActorAvatarEditComponent implements OnInit {
     formData.append('avatarfile', avatarfile)
     this.avatarPopover?.close()
     this.avatarChange.emit(formData)
 
     if (this.previewImage) {
-      this.preview = this.sanitizer.bypassSecurityTrustResourceUrl(URL.createObjectURL(avatarfile))
+      imageToDataURL(avatarfile).then(result => this.preview = result)
     }
   }
 
   deleteAvatar () {
     this.preview = undefined
diff --git a/client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.ts b/client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.ts
index cba2c5db3..47b537b74 100644
--- a/client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.ts
+++ b/client/src/app/shared/shared-actor-image-edit/actor-banner-edit.component.ts
@@ -1,11 +1,12 @@
 import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
+import { SafeResourceUrl } from '@angular/platform-browser'
 import { Notifier, ServerService } from '@app/core'
 import { VideoChannel } from '@app/shared/shared-main'
 import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
 import { getBytes } from '@root-helpers/bytes'
+import { imageToDataURL } from '@root-helpers/images'
 
 @Component({
   selector: 'my-actor-banner-edit',
   templateUrl: './actor-banner-edit.component.html',
   styleUrls: [
@@ -28,11 +29,10 @@ export class ActorBannerEditComponent implements OnInit {
   bannerExtensions = ''
 
   preview: SafeResourceUrl
 
   constructor (
-    private sanitizer: DomSanitizer,
     private serverService: ServerService,
     private notifier: Notifier
   ) { }
 
   ngOnInit (): void {
@@ -57,11 +57,11 @@ export class ActorBannerEditComponent implements OnInit {
     formData.append('bannerfile', bannerfile)
     this.bannerPopover?.close()
     this.bannerChange.emit(formData)
 
     if (this.previewImage) {
-      this.preview = this.sanitizer.bypassSecurityTrustResourceUrl(URL.createObjectURL(bannerfile))
+      imageToDataURL(bannerfile).then(result => this.preview = result)
     }
   }
 
   deleteBanner () {
     this.preview = undefined
diff --git a/client/src/app/shared/shared-actor-image/actor-avatar.component.ts b/client/src/app/shared/shared-actor-image/actor-avatar.component.ts
index a4adfd1b7..c323dc724 100644
--- a/client/src/app/shared/shared-actor-image/actor-avatar.component.ts
+++ b/client/src/app/shared/shared-actor-image/actor-avatar.component.ts
@@ -1,7 +1,6 @@
 import { Component, Input } from '@angular/core'
-import { SafeResourceUrl } from '@angular/platform-browser'
 import { VideoChannel } from '../shared-main'
 import { Account } from '../shared-main/account/account.model'
 
 type ActorInput = {
   name: string
@@ -20,11 +19,11 @@ export class ActorAvatarComponent {
   private _title: string
 
   @Input() account: ActorInput
   @Input() channel: ActorInput
 
-  @Input() previewImage: SafeResourceUrl
+  @Input() previewImage: string
 
   @Input() size: ActorAvatarSize
 
   // Use an external link
   @Input() href: string
diff --git a/client/src/app/shared/shared-forms/preview-upload.component.ts b/client/src/app/shared/shared-forms/preview-upload.component.ts
index edee6786d..a857b0a4f 100644
--- a/client/src/app/shared/shared-forms/preview-upload.component.ts
+++ b/client/src/app/shared/shared-forms/preview-upload.component.ts
@@ -1,9 +1,9 @@
 import { Component, forwardRef, Input, OnInit } from '@angular/core'
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
-import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
 import { ServerService } from '@app/core'
+import { imageToDataURL } from '@root-helpers/images'
 import { HTMLServerConfig } from '@shared/models'
 import { BytesPipe } from '../shared-main'
 
 @Component({
   selector: 'my-preview-upload',
@@ -21,20 +21,19 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
   @Input() inputLabel: string
   @Input() inputName: string
   @Input() previewWidth: string
   @Input() previewHeight: string
 
-  imageSrc: SafeResourceUrl
+  imageSrc: string
   allowedExtensionsMessage = ''
   maxSizeText: string
 
   private serverConfig: HTMLServerConfig
   private bytesPipe: BytesPipe
   private file: Blob
 
   constructor (
-    private sanitizer: DomSanitizer,
     private serverService: ServerService
   ) {
     this.bytesPipe = new BytesPipe()
     this.maxSizeText = $localize`max size`
   }
@@ -79,10 +78,9 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
     // Unused
   }
 
   private updatePreview () {
     if (this.file) {
-      const url = URL.createObjectURL(this.file)
-      this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url)
+      imageToDataURL(this.file).then(result => this.imageSrc = result)
     }
   }
 }
diff --git a/client/src/root-helpers/images.ts b/client/src/root-helpers/images.ts
new file mode 100644
index 000000000..fb229ce6d
--- /dev/null
+++ b/client/src/root-helpers/images.ts
@@ -0,0 +1,13 @@
+function imageToDataURL (input: File | Blob) {
+  return new Promise<string>(res => {
+    const reader = new FileReader()
+
+    reader.onerror = err => console.error('Cannot read input file.', err)
+    reader.onloadend = () => res(reader.result as string)
+    reader.readAsDataURL(input)
+  })
+}
+
+export {
+  imageToDataURL
+}
diff --git a/client/src/root-helpers/index.ts b/client/src/root-helpers/index.ts
index d62f07f9e..63d55d7ef 100644
--- a/client/src/root-helpers/index.ts
+++ b/client/src/root-helpers/index.ts
@@ -1,5 +1,6 @@
 export * from './users'
 export * from './bytes'
+export * from './images'
 export * from './peertube-web-storage'
 export * from './utils'
 export * from './plugins-manager'

Vulnerable code: import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
  preview: SafeResourceUrl
    private sanitizer: DomSanitizer,
      this.preview = this.sanitizer.bypassSecurityTrustResourceUrl(URL.createObjectURL(avatarfile))
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
    private sanitizer: DomSanitizer,
      this.preview = this.sanitizer.bypassSecurityTrustResourceUrl(URL.createObjectURL(bannerfile))
import { SafeResourceUrl } from '@angular/platform-browser'
  @Input() previewImage: SafeResourceUrl
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
  imageSrc: SafeResourceUrl
    private sanitizer: DomSanitizer,
      const url = URL.createObjectURL(this.file)
      this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url)
Fixed code: import { imageToDataURL } from '@root-helpers/images'
  preview: string
      imageToDataURL(avatarfile).then(result => this.preview = result)
import { SafeResourceUrl } from '@angular/platform-browser'
import { imageToDataURL } from '@root-helpers/images'
      imageToDataURL(bannerfile).then(result => this.preview = result)
  @Input() previewImage: string
import { imageToDataURL } from '@root-helpers/images'
  imageSrc: string
      imageToDataURL(this.file).then(result => this.imageSrc = result)
function imageToDataURL (input: File | Blob) {
  return new Promise<string>(res => {
    const reader = new FileReader()

    reader.onerror = err => console.error('Cannot read input file.', err)
    reader.onloadend = () => res(reader.result as string)
    reader.readAsDataURL(input)
  })
}

export {
  imageToDataURL
}
export * from './images'