CVE-2021-23495: URL Redirection to Untrusted Site ('Open Redirect') (CWE-601)
value: The package karma before 6.3.16 are vulnerable to Open Redirect due to missing validation of the return_url query parameter.
CWE description: A web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a Redirect. This simplifies phishing attacks.
CVSS v3 base score: 6.1
Published: 2022-02-25T20:15Z
Repository: https://github.com/karma-runner/karma
Language: JavaScript
Files changed: client/karma.js, docs/config/01-configuration-file.md, lib/config.js, static/karma.js, test/client/karma.spec.js
Commit date: 2022-02-10 12:25:22 -0600
Commit message: fix(security): mitigate the "Open Redirect Vulnerability"

Diff: diff --git a/client/karma.js b/client/karma.js
index 5065103..9e13d89 100644
--- a/client/karma.js
+++ b/client/karma.js
@@ -237,11 +237,19 @@ function Karma (updater, socket, iframe, opener, navigator, location, document)
       navigateContextTo('about:blank')
     } else {
       self.updater.updateTestStatus('complete')
     }
     if (returnUrl) {
-      if (!/^https?:\/\//.test(returnUrl)) {
+      var isReturnUrlAllowed = false
+      for (var i = 0; i < this.config.allowedReturnUrlPatterns.length; i++) {
+        var allowedReturnUrlPattern = new RegExp(this.config.allowedReturnUrlPatterns[i])
+        if (allowedReturnUrlPattern.test(returnUrl)) {
+          isReturnUrlAllowed = true
+          break
+        }
+      }
+      if (!isReturnUrlAllowed) {
         throw new Error(
           'Security: Navigation to '.concat(
             returnUrl,
             ' was blocked to prevent malicious exploits.'
           )
diff --git a/docs/config/01-configuration-file.md b/docs/config/01-configuration-file.md
index ce83ec2..dff4b61 100644
--- a/docs/config/01-configuration-file.md
+++ b/docs/config/01-configuration-file.md
@@ -275,10 +275,19 @@ upon the completion of running the tests. Setting this to false is useful when e
 
 **Description:** Set style display none on client elements.
 
 If true, Karma does not display the banner and browser list. Useful when using karma on component tests with screenshots.
 
+## client.allowedReturnUrlPatterns
+**Type:** Array
+
+**Default:** `['^https?://']`
+
+**Description:** Define the string representations of the regular expressions that will be allowed for the `return_url` query parameter.
+
+If the value of the `return_url` query parameter does not match any regular expression derived from the string representation of each of the elements of this array, navigation to it will be blocked.
+
 ## colors
 **Type:** Boolean
 
 **Default:**   `true`
 
diff --git a/lib/config.js b/lib/config.js
index cd9510b..240e1c3 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -15,23 +15,23 @@ let LIVE_SCRIPT_AVAILABLE = false
 let TYPE_SCRIPT_AVAILABLE = false
 
 try {
   require('coffeescript').register()
   COFFEE_SCRIPT_AVAILABLE = true
-} catch (e) {}
+} catch {}
 
 // LiveScript is required here to enable config files written in LiveScript.
 // It's not directly used in this file.
 try {
   require('LiveScript')
   LIVE_SCRIPT_AVAILABLE = true
-} catch (e) {}
+} catch {}
 
 try {
   require('ts-node')
   TYPE_SCRIPT_AVAILABLE = true
-} catch (e) {}
+} catch {}
 
 class Pattern {
   constructor (pattern, served, included, watched, nocache, type, isBinary) {
     this.pattern = pattern
     this.served = helper.isDefined(served) ? served : true
@@ -322,11 +322,12 @@ class Config {
     this.defaultClient = this.client = {
       args: [],
       useIframe: true,
       runInParent: false,
       captureConsole: true,
-      clearContext: true
+      clearContext: true,
+      allowedReturnUrlPatterns: ['^https?://']
     }
     this.browserDisconnectTimeout = 2000
     this.browserDisconnectTolerance = 0
     this.browserNoActivityTimeout = 30000
     this.processKillTimeout = 2000
diff --git a/static/karma.js b/static/karma.js
index f0b2548..23081eb 100644
--- a/static/karma.js
+++ b/static/karma.js
@@ -247,11 +247,19 @@ function Karma (updater, socket, iframe, opener, navigator, location, document)
       navigateContextTo('about:blank')
     } else {
       self.updater.updateTestStatus('complete')
     }
     if (returnUrl) {
-      if (!/^https?:\/\//.test(returnUrl)) {
+      var isReturnUrlAllowed = false
+      for (var i = 0; i < this.config.allowedReturnUrlPatterns.length; i++) {
+        var allowedReturnUrlPattern = new RegExp(this.config.allowedReturnUrlPatterns[i])
+        if (allowedReturnUrlPattern.test(returnUrl)) {
+          isReturnUrlAllowed = true
+          break
+        }
+      }
+      if (!isReturnUrlAllowed) {
         throw new Error(
           'Security: Navigation to '.concat(
             returnUrl,
             ' was blocked to prevent malicious exploits.'
           )
diff --git a/test/client/karma.spec.js b/test/client/karma.spec.js
index 3e7af73..b88ebda 100644
--- a/test/client/karma.spec.js
+++ b/test/client/karma.spec.js
@@ -440,19 +440,22 @@ describe('Karma', function () {
 
       ck.complete()
       assert(spyResult.called)
     })
 
-    it('should navigate the client to return_url if specified', function (done) {
+    it('should navigate the client to return_url if specified and allowed', function (done) {
+      var config = {
+        // The default value.
+        allowedReturnUrlPatterns: ['^https?://']
+      }
       windowLocation.search = '?id=567&return_url=http://return.com'
       socket = new MockSocket()
       k = new ClientKarma(updater, socket, iframe, windowStub, windowNavigator, windowLocation)
       clientWindow = { karma: k }
       ck = new ContextKarma(ContextKarma.getDirectCallParentKarmaMethod(clientWindow))
-      ck.config = {}
+      socket.emit('execute', config)
 
-      sinon.spy(socket, 'disconnect')
       clock.tick(500)
 
       ck.complete()
       setTimeout(function () {
         assert(windowLocation.href === 'http://return.com')
@@ -460,10 +463,30 @@ describe('Karma', function () {
       }, 5)
 
       clock.tick(10)
     })
 
+    it('should not navigate the client to return_url if not allowed', function () {
+      var config = {
+        allowedReturnUrlPatterns: []
+      }
+
+      windowLocation.search = '?id=567&return_url=javascript:alert(document.domain)'
+      socket = new MockSocket()
+      k = new ClientKarma(updater, socket, iframe, windowStub, windowNavigator, windowLocation)
+      clientWindow = { karma: k }
+      ck = new ContextKarma(ContextKarma.getDirectCallParentKarmaMethod(clientWindow))
+      socket.emit('execute', config)
+
+      try {
+        ck.complete()
+        throw new Error('An error should have been caught.')
+      } catch (error) {
+        assert(/Error: Security: Navigation to .* was blocked to prevent malicious exploits./.test(error))
+      }
+    })
+
     it('should clear context window upon complete when clearContext config is true', function () {
       var config = ck.config = {
         clearContext: true
       }
 

Vulnerable code:       if (!/^https?:\/\//.test(returnUrl)) {
} catch (e) {}
} catch (e) {}
} catch (e) {}
      clearContext: true
      if (!/^https?:\/\//.test(returnUrl)) {
    it('should navigate the client to return_url if specified', function (done) {
      ck.config = {}
      sinon.spy(socket, 'disconnect')
Fixed code:       var isReturnUrlAllowed = false
      for (var i = 0; i < this.config.allowedReturnUrlPatterns.length; i++) {
        var allowedReturnUrlPattern = new RegExp(this.config.allowedReturnUrlPatterns[i])
        if (allowedReturnUrlPattern.test(returnUrl)) {
          isReturnUrlAllowed = true
          break
        }
      }
      if (!isReturnUrlAllowed) {
## client.allowedReturnUrlPatterns
**Type:** Array

**Default:** `['^https?://']`

**Description:** Define the string representations of the regular expressions that will be allowed for the `return_url` query parameter.

If the value of the `return_url` query parameter does not match any regular expression derived from the string representation of each of the elements of this array, navigation to it will be blocked.

} catch {}
} catch {}
} catch {}
      clearContext: true,
      allowedReturnUrlPatterns: ['^https?://']
      var isReturnUrlAllowed = false
      for (var i = 0; i < this.config.allowedReturnUrlPatterns.length; i++) {
        var allowedReturnUrlPattern = new RegExp(this.config.allowedReturnUrlPatterns[i])
        if (allowedReturnUrlPattern.test(returnUrl)) {
          isReturnUrlAllowed = true
          break
        }
      }
      if (!isReturnUrlAllowed) {
    it('should navigate the client to return_url if specified and allowed', function (done) {
      var config = {
        // The default value.
        allowedReturnUrlPatterns: ['^https?://']
      }
      socket.emit('execute', config)
    it('should not navigate the client to return_url if not allowed', function () {
      var config = {
        allowedReturnUrlPatterns: []
      }

      windowLocation.search = '?id=567&return_url=javascript:alert(document.domain)'
      socket = new MockSocket()
      k = new ClientKarma(updater, socket, iframe, windowStub, windowNavigator, windowLocation)
      clientWindow = { karma: k }
      ck = new ContextKarma(ContextKarma.getDirectCallParentKarmaMethod(clientWindow))
      socket.emit('execute', config)

      try {
        ck.complete()
        throw new Error('An error should have been caught.')
      } catch (error) {
        assert(/Error: Security: Navigation to .* was blocked to prevent malicious exploits./.test(error))
      }
    })