Repository: karma-runner/karma
Pull request #3797 (2022-05-16 03:52:48)

Support subresource integrity verification
https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity

falsandtru: @jginsburgn @devoto13 Can you approve the CI tests?
falsandtru: @jginsburgn @devoto13 Without integrity checking, your local machines and CI servers are at risk of being compromised by falsification of remote resources. Can you review it and support integrity checking?
falsandtru: OK
falsandtru: Fixed the too long commit headers.
falsandtru: @jginsburgn Can you review?
jginsburgn: I am sorry I had not been able to review this PR! I am unfamiliar with Integrity Checking. Let me get familiarized and review this merge request promptly.

Patch:
diff --git a/docs/config/02-files.md b/docs/config/02-files.md
index aac9304e..0bab8256 100644
--- a/docs/config/02-files.md
+++ b/docs/config/02-files.md
@@ -54,6 +54,11 @@ The `files` array determines which files are included in the browser, watched, a
 * **Default.** `false`
 * **Description.** Should the files be served from disk on each request by Karma's webserver?
 
+### `integrity`
+* **Type.** String
+* **Default.** `undefined`
+* **Description.** Set the `integrity` HTML attribute value to the `<script>` or the `<link>` tag to load the resource that matches the given pattern if the pattern is an absolute URL.
+
 ## Pattern matching and `basePath`
 - All of the relative patterns will get resolved using the `basePath` first.
 - If the `basePath` is a relative path, it gets resolved to the
diff --git a/lib/config.js b/lib/config.js
index 240e1c30..0dff33c9 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -32,7 +32,7 @@ try {
 } catch {}
 
 class Pattern {
-  constructor (pattern, served, included, watched, nocache, type, isBinary) {
+  constructor (pattern, served, included, watched, nocache, type, isBinary, integrity) {
     this.pattern = pattern
     this.served = helper.isDefined(served) ? served : true
     this.included = helper.isDefined(included) ? included : true
@@ -41,6 +41,7 @@ class Pattern {
     this.weight = helper.mmPatternWeight(pattern)
     this.type = type
     this.isBinary = isBinary
+    this.integrity = integrity
   }
 
   compare (other) {
@@ -49,8 +50,8 @@ class Pattern {
 }
 
 class UrlPattern extends Pattern {
-  constructor (url, type) {
-    super(url, false, true, false, false, type)
+  constructor (url, type, integrity) {
+    super(url, false, true, false, false, type, undefined, integrity)
   }
 }
 
diff --git a/lib/file-list.js b/lib/file-list.js
index 6c9e93d1..b4ec01d2 100644
--- a/lib/file-list.js
+++ b/lib/file-list.js
@@ -62,9 +62,9 @@ class FileList {
 
     let lastCompletedRefresh = this._refreshing
     lastCompletedRefresh = Promise.all(
-      this._patterns.map(async ({ pattern, type, nocache, isBinary }) => {
+      this._patterns.map(async ({ pattern, type, nocache, isBinary, integrity }) => {
         if (helper.isUrlAbsolute(pattern)) {
-          this.buckets.set(pattern, [new Url(pattern, type)])
+          this.buckets.set(pattern, [new Url(pattern, type, integrity)])
           return
         }
 
diff --git a/lib/file.js b/lib/file.js
index 2342fd3a..698aca7d 100644
--- a/lib/file.js
+++ b/lib/file.js
@@ -6,7 +6,7 @@ const path = require('path')
  * File object used for tracking files in `file-list.js`.
  */
 class File {
-  constructor (path, mtime, doNotCache, type, isBinary) {
+  constructor (path, mtime, doNotCache, type, isBinary, integrity) {
     // used for serving (processed path, eg some/file.coffee -> some/file.coffee.js)
     this.path = path
 
@@ -29,6 +29,8 @@ class File {
 
     // Tri state: null means probe file for binary.
     this.isBinary = isBinary === undefined ? null : isBinary
+
+    this.integrity = integrity
   }
 
   /**
diff --git a/lib/middleware/karma.js b/lib/middleware/karma.js
index a5c94f39..355a9b63 100644
--- a/lib/middleware/karma.js
+++ b/lib/middleware/karma.js
@@ -182,19 +182,20 @@ function createKarmaMiddleware (
               }
             }
 
+            const integrityAttribute = file.integrity ? ` integrity="${file.integrity}"` : ''
+            const crossOriginAttribute = includeCrossOriginAttribute ? ' crossorigin="anonymous"' : ''
             if (fileType === 'css') {
-              scriptTags.push(`<link type="text/css" href="${filePath}" rel="stylesheet">`)
+              scriptTags.push(`<link type="text/css" href="${filePath}" rel="stylesheet"${integrityAttribute}${crossOriginAttribute}>`)
             } else if (fileType === 'dom') {
               scriptTags.push(file.content)
             } else if (fileType === 'html') {
-              scriptTags.push(`<link href="${filePath}" rel="import">`)
+              scriptTags.push(`<link href="${filePath}" rel="import"${integrityAttribute}${crossOriginAttribute}>`)
             } else {
               const scriptType = (SCRIPT_TYPE[fileType] || 'text/javascript')
-              const crossOriginAttribute = includeCrossOriginAttribute ? 'crossorigin="anonymous"' : ''
               if (fileType === 'module') {
-                scriptTags.push(`<script onerror="throw 'Error loading ${filePath}'" type="${scriptType}" src="${filePath}" ${crossOriginAttribute}></script>`)
+                scriptTags.push(`<script onerror="throw 'Error loading ${filePath}'" type="${scriptType}" src="${filePath}"${integrityAttribute}${crossOriginAttribute}></script>`)
               } else {
-                scriptTags.push(`<script type="${scriptType}" src="${filePath}" ${crossOriginAttribute}></script>`)
+                scriptTags.push(`<script type="${scriptType}" src="${filePath}"${integrityAttribute}${crossOriginAttribute}></script>`)
               }
             }
           }
diff --git a/lib/url.js b/lib/url.js
index ebe07861..fce5bd1d 100644
--- a/lib/url.js
+++ b/lib/url.js
@@ -7,10 +7,11 @@ const { URL } = require('url')
  * Url object used for tracking files in `file-list.js`.
  */
 class Url {
-  constructor (path, type) {
+  constructor (path, type, integrity) {
     this.path = path
     this.originalPath = path
     this.type = type
+    this.integrity = integrity
     this.isUrl = true
   }
 


Test patch:
diff --git a/test/unit/middleware/karma.spec.js b/test/unit/middleware/karma.spec.js
index 4f6b873c..db9c5819 100644
--- a/test/unit/middleware/karma.spec.js
+++ b/test/unit/middleware/karma.spec.js
@@ -17,8 +17,8 @@ describe('middleware.karma', () => {
   let response
 
   class MockFile extends File {
-    constructor (path, sha, type, content) {
-      super(path, undefined, undefined, type)
+    constructor (path, sha, type, content, integrity) {
+      super(path, undefined, undefined, type, undefined, integrity)
       this.sha = sha || 'sha-default'
       this.content = content
     }
@@ -230,6 +230,21 @@ describe('middleware.karma', () => {
     callHandlerWith('/__karma__/context.html')
   })
 
+  it('should serve context.html with script tags with integrity checking', (done) => {
+    includedFiles([
+      new MockFile('/first.js', 'sha123'),
+      new MockFile('/second.js', 'sha456', undefined, undefined, 'sha256-XXX')
+    ])
+
+    response.once('end', () => {
+      expect(nextSpy).not.to.have.been.called
+      expect(response).to.beServedAs(200, 'CONTEXT\n<script type="text/javascript" src="/__proxy__/__karma__/absolute/first.js?sha123" crossorigin="anonymous"></script>\n<script type="text/javascript" src="/__proxy__/__karma__/absolute/second.js?sha456" integrity="sha256-XXX" crossorigin="anonymous"></script>')
+      done()
+    })
+
+    callHandlerWith('/__karma__/context.html')
+  })
+
   it('should serve context.html with replaced link tags', (done) => {
     includedFiles([
       new MockFile('/first.css', 'sha007'),
@@ -242,7 +257,7 @@ describe('middleware.karma', () => {
 
     response.once('end', () => {
       expect(nextSpy).not.to.have.been.called
-      expect(response).to.beServedAs(200, 'CONTEXT\n<link type="text/css" href="/__proxy__/__karma__/absolute/first.css?sha007" rel="stylesheet">\n<link href="/__proxy__/__karma__/absolute/second.html?sha678" rel="import">\n<link type="text/css" href="/__proxy__/__karma__/absolute/third?sha111" rel="stylesheet">\n<link href="/__proxy__/__karma__/absolute/fourth?sha222" rel="import">\n<link type="text/css" href="http://some.url.com/fifth" rel="stylesheet">\n<link href="http://some.url.com/sixth" rel="import">')
+      expect(response).to.beServedAs(200, 'CONTEXT\n<link type="text/css" href="/__proxy__/__karma__/absolute/first.css?sha007" rel="stylesheet" crossorigin="anonymous">\n<link href="/__proxy__/__karma__/absolute/second.html?sha678" rel="import" crossorigin="anonymous">\n<link type="text/css" href="/__proxy__/__karma__/absolute/third?sha111" rel="stylesheet" crossorigin="anonymous">\n<link href="/__proxy__/__karma__/absolute/fourth?sha222" rel="import" crossorigin="anonymous">\n<link type="text/css" href="http://some.url.com/fifth" rel="stylesheet" crossorigin="anonymous">\n<link href="http://some.url.com/sixth" rel="import" crossorigin="anonymous">')
       done()
     })
 
@@ -278,7 +293,22 @@ describe('middleware.karma', () => {
 
     response.once('end', () => {
       expect(nextSpy).not.to.have.been.called
-      expect(response).to.beServedAs(200, 'CONTEXT\n<link type="text/css" href="/__proxy__/__karma__/absolute/some/abc/a.css?sha1" rel="stylesheet">\n<link type="text/css" href="/__proxy__/__karma__/base/b.css?sha2" rel="stylesheet">\n<link href="/__proxy__/__karma__/absolute/some/abc/c.html?sha3" rel="import">\n<link href="/__proxy__/__karma__/base/d.html?sha4" rel="import">\n<link type="text/css" href="/__proxy__/__karma__/absolute/some/abc/e?sha5" rel="stylesheet">\n<link type="text/css" href="/__proxy__/__karma__/base/f?sha6" rel="stylesheet">\n<link href="/__proxy__/__karma__/absolute/some/abc/g?sha7" rel="import">\n<link href="/__proxy__/__karma__/base/h?sha8" rel="import">')
+      expect(response).to.beServedAs(200, 'CONTEXT\n<link type="text/css" href="/__proxy__/__karma__/absolute/some/abc/a.css?sha1" rel="stylesheet" crossorigin="anonymous">\n<link type="text/css" href="/__proxy__/__karma__/base/b.css?sha2" rel="stylesheet" crossorigin="anonymous">\n<link href="/__proxy__/__karma__/absolute/some/abc/c.html?sha3" rel="import" crossorigin="anonymous">\n<link href="/__proxy__/__karma__/base/d.html?sha4" rel="import" crossorigin="anonymous">\n<link type="text/css" href="/__proxy__/__karma__/absolute/some/abc/e?sha5" rel="stylesheet" crossorigin="anonymous">\n<link type="text/css" href="/__proxy__/__karma__/base/f?sha6" rel="stylesheet" crossorigin="anonymous">\n<link href="/__proxy__/__karma__/absolute/some/abc/g?sha7" rel="import" crossorigin="anonymous">\n<link href="/__proxy__/__karma__/base/h?sha8" rel="import" crossorigin="anonymous">')
+      done()
+    })
+
+    callHandlerWith('/__karma__/context.html')
+  })
+
+  it('should serve context.html with link tags with integrity checking', (done) => {
+    includedFiles([
+      new MockFile('/first.css', 'sha007', undefined, undefined, 'sha256-XXX'),
+      new MockFile('/second.html', 'sha678', undefined, undefined, 'sha256-XXX')
+    ])
+
+    response.once('end', () => {
+      expect(nextSpy).not.to.have.been.called
+      expect(response).to.beServedAs(200, 'CONTEXT\n<link type="text/css" href="/__proxy__/__karma__/absolute/first.css?sha007" rel="stylesheet" integrity="sha256-XXX" crossorigin="anonymous">\n<link href="/__proxy__/__karma__/absolute/second.html?sha678" rel="import" integrity="sha256-XXX" crossorigin="anonymous">')
       done()
     })
 
@@ -447,7 +477,7 @@ describe('middleware.karma', () => {
 
     response.once('end', () => {
       expect(nextSpy).not.to.have.been.called
-      expect(response).to.beServedAs(200, 'DEBUG\n<link type="text/css" href="/__proxy__/__karma__/absolute/first.css" rel="stylesheet">\n<link type="text/css" href="/__proxy__/__karma__/base/b.css" rel="stylesheet">\n<link href="/__proxy__/__karma__/absolute/second.html" rel="import">\n<link href="/__proxy__/__karma__/base/d.html" rel="import">\n<link type="text/css" href="/__proxy__/__karma__/absolute/third" rel="stylesheet">\n<link type="text/css" href="/__proxy__/__karma__/base/f" rel="stylesheet">\n<link href="/__proxy__/__karma__/absolute/fourth" rel="import">\n<link href="/__proxy__/__karma__/base/g" rel="import">')
+      expect(response).to.beServedAs(200, 'DEBUG\n<link type="text/css" href="/__proxy__/__karma__/absolute/first.css" rel="stylesheet" crossorigin="anonymous">\n<link type="text/css" href="/__proxy__/__karma__/base/b.css" rel="stylesheet" crossorigin="anonymous">\n<link href="/__proxy__/__karma__/absolute/second.html" rel="import" crossorigin="anonymous">\n<link href="/__proxy__/__karma__/base/d.html" rel="import" crossorigin="anonymous">\n<link type="text/css" href="/__proxy__/__karma__/absolute/third" rel="stylesheet" crossorigin="anonymous">\n<link type="text/css" href="/__proxy__/__karma__/base/f" rel="stylesheet" crossorigin="anonymous">\n<link href="/__proxy__/__karma__/absolute/fourth" rel="import" crossorigin="anonymous">\n<link href="/__proxy__/__karma__/base/g" rel="import" crossorigin="anonymous">')
       done()
     })