CVE-2022-1432: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Cross-site Scripting (XSS) - Generic in GitHub repository octoprint/octoprint prior to 1.8.0.
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.4
Published: 2022-05-18T14:15Z
Repository: https://github.com/octoprint/octoprint
Language: JavaScript
Files changed: src/octoprint/static/js/app/helpers.js, src/octoprint/static/js/app/viewmodels/settings.js, src/octoprint/templates/snippets/settings/webcam/webcamStreamUrl.jinja2
Commit date: 2022-05-11 16:40:07 +0200
Commit message: 🔒️ Fix XSS in webcam stream test

Diff: diff --git a/src/octoprint/static/js/app/helpers.js b/src/octoprint/static/js/app/helpers.js
index fcea464ef..fc288aa1f 100644
--- a/src/octoprint/static/js/app/helpers.js
+++ b/src/octoprint/static/js/app/helpers.js
@@ -1540,45 +1540,78 @@ var copyToClipboard = function (text) {
     temp.val(text).select();
     document.execCommand("copy");
     temp.remove();
 };
 
-var determineWebcamStreamType = function (streamUrl) {
-    if (streamUrl) {
-        if (streamUrl.startsWith("webrtc")) {
-            return "webrtc";
-        }
+var getExternalHostUrl = function () {
+    var loc = window.location;
+    var port = "";
+    if (
+        (loc.protocol === "http:" && loc.port !== "80") ||
+        (loc.protocol === "https:" && loc.port !== "443")
+    ) {
+        port = ":" + loc.port;
+    }
+    return loc.protocol + "//" + loc.hostname + port;
+};
 
-        var lastDotPosition = streamUrl.lastIndexOf(".");
-        var firstQuotationSignPosition = streamUrl.indexOf("?");
-        if (
-            lastDotPosition != -1 &&
-            firstQuotationSignPosition != -1 &&
-            lastDotPosition >= firstQuotationSignPosition
-        ) {
-            throw "Malformed URL. Cannot determine stream type.";
-        }
+var validateWebcamUrl = function (streamUrl) {
+    if (!streamUrl) {
+        return false;
+    }
 
-        // If we have found a dot, try to extract the extension.
-        if (lastDotPosition > -1) {
-            if (firstQuotationSignPosition > -1) {
-                var extension = streamUrl.slice(
-                    lastDotPosition + 1,
-                    firstQuotationSignPosition - 1
-                );
-            } else {
-                var extension = streamUrl.slice(lastDotPosition + 1);
-            }
-            if (extension.toLowerCase() == "m3u8") {
-                return "hls";
-            }
-        }
-        // By default, 'mjpg' is the stream type.
-        return "mjpg";
+    var lower = streamUrl.toLowerCase();
+    var toParse = streamUrl;
+
+    if (lower.startsWith("//")) {
+        // protocol relative
+        toParse = window.location.protocol + streamUrl;
+    } else if (lower.startsWith("/")) {
+        // host relative
+        toParse = getExternalHostUrl() + streamUrl;
+    } else if (
+        lower.startsWith("http:") ||
+        lower.startsWith("https:") ||
+        lower.startsWith("webrtc:")
+    ) {
+        // absolute & http/https/webrtc
+        toParse = streamUrl;
     } else {
+        return false;
+    }
+
+    try {
+        return new URL(toParse);
+    } catch (e) {
+        return false;
+    }
+};
+
+var determineWebcamStreamType = function (streamUrl) {
+    if (!streamUrl) {
         throw "Empty streamUrl. Cannot determine stream type.";
     }
+
+    var parsed = validateWebcamUrl(streamUrl);
+    if (!parsed) {
+        throw "Invalid streamUrl. Cannot determine stream type.";
+    }
+
+    if (parsed.protocol === "webrtc:") {
+        return "webrtc";
+    }
+
+    var lastDotPosition = parsed.pathname.lastIndexOf(".");
+    if (lastDotPosition !== -1) {
+        var extension = parsed.pathname.substring(lastDotPosition + 1);
+        if (extension.toLowerCase() === "m3u8") {
+            return "hls";
+        }
+    }
+
+    // By default, 'mjpg' is the stream type.
+    return "mjpg";
 };
 
 var saveToLocalStorage = function (key, data) {
     if (!Modernizr.localstorage) return;
     localStorage[key] = JSON.stringify(data);
diff --git a/src/octoprint/static/js/app/viewmodels/settings.js b/src/octoprint/static/js/app/viewmodels/settings.js
index 838c56735..d3644b8d9 100644
--- a/src/octoprint/static/js/app/viewmodels/settings.js
+++ b/src/octoprint/static/js/app/viewmodels/settings.js
@@ -317,10 +317,14 @@ $(function () {
                 return determineWebcamStreamType(self.webcam_streamUrl());
             } catch (e) {
                 return "";
             }
         });
+        self.webcam_streamValid = ko.pureComputed(function () {
+            var url = self.webcam_streamUrl();
+            return !url || validateWebcamUrl(url);
+        });
 
         self.server_onlineCheckText = ko.observable();
         self.server_onlineCheckOk = ko.observable(false);
         self.server_onlineCheckBroken = ko.observable(false);
         self.server_onlineCheckReset = function () {
@@ -448,16 +452,23 @@ $(function () {
             }
 
             var text = gettext(
                 "If you see your webcam stream below, the entered stream URL is ok."
             );
-            var streamType = self.webcam_streamType();
+
+            var streamType;
+            try {
+                streamType = self.webcam_streamType();
+            } catch (e) {
+                streamType = "";
+            }
+
             var webcam_element;
             var webrtc_peer_connection;
-            if (streamType == "mjpg") {
+            if (streamType === "mjpg") {
                 webcam_element = $('<img src="' + self.webcam_streamUrl() + '">');
-            } else if (streamType == "hls") {
+            } else if (streamType === "hls") {
                 webcam_element = $(
                     '<video id="webcam_hls" muted autoplay style="width: 100%"/>'
                 );
                 video_element = webcam_element[0];
                 if (video_element.canPlayType("application/vnd.apple.mpegurl")) {
@@ -465,11 +476,11 @@ $(function () {
                 } else if (Hls.isSupported()) {
                     var hls = new Hls();
                     hls.loadSource(self.webcam_streamUrl());
                     hls.attachMedia(video_element);
                 }
-            } else if (isWebRTCAvailable() && streamType == "webrtc") {
+            } else if (isWebRTCAvailable() && streamType === "webrtc") {
                 webcam_element = $(
                     '<video id="webcam_webrtc" muted autoplay playsinline controls style="width: 100%"/>'
                 );
                 video_element = webcam_element[0];
 
diff --git a/src/octoprint/templates/snippets/settings/webcam/webcamStreamUrl.jinja2 b/src/octoprint/templates/snippets/settings/webcam/webcamStreamUrl.jinja2
index 3839d826e..5c47514b6 100644
--- a/src/octoprint/templates/snippets/settings/webcam/webcamStreamUrl.jinja2
+++ b/src/octoprint/templates/snippets/settings/webcam/webcamStreamUrl.jinja2
@@ -1,11 +1,11 @@
-<div class="control-group" title="{{ _('URL to embed into the UI for live viewing of the webcam stream')|edq }}">
+<div class="control-group" title="{{ _('URL to embed into the UI for live viewing of the webcam stream')|edq }}" data-bind="css: { error: !webcam_streamValid() }">
     <label class="control-label" for="settings-webcamStreamUrl">{{ _('Stream URL') }}</label>
     <div class="controls">
         <div class="input-append">
             <input type="text" class="input-block-level" data-bind="value: webcam_streamUrl, valueUpdate: 'afterkeydown'" id="settings-webcamStreamUrl">
-            <button class="btn" type="button" data-bind="click: testWebcamStreamUrl, enable: webcam_streamUrl() && !testWebcamStreamUrlBusy(), css: {disabled: !webcam_streamUrl() || testWebcamStreamUrlBusy()}"><i class="fas fa-spinner fa-spin" data-bind="visible: testWebcamStreamUrlBusy"></i> {{ _('Test') }}</button>
+            <button class="btn" type="button" data-bind="click: testWebcamStreamUrl, enable: !testWebcamStreamUrlBusy() && webcam_streamValid(), css: {disabled: testWebcamStreamUrlBusy() || !webcam_streamValid()}"><i class="fas fa-spinner fa-spin" data-bind="visible: testWebcamStreamUrlBusy"></i> {{ _('Test') }}</button>
         </div>
         <span class="help-block">
             <p>{% trans %}Needs to be reachable from the browser displaying the OctoPrint UI, used to embed the webcam stream into the page.{% endtrans %}</p>
             <p data-bind="visible: webcam_streamType() === 'webrtc'">{% trans %}<strong>Heads-up!</strong> WebRTC support is still in beta and subject to change, monitor the changelog of future versions closely for heads-ups regarding the WebRTC support in OctoPrint.{% endtrans %}</p>
         </span>

Vulnerable code: var determineWebcamStreamType = function (streamUrl) {
    if (streamUrl) {
        if (streamUrl.startsWith("webrtc")) {
            return "webrtc";
        }
        var lastDotPosition = streamUrl.lastIndexOf(".");
        var firstQuotationSignPosition = streamUrl.indexOf("?");
        if (
            lastDotPosition != -1 &&
            firstQuotationSignPosition != -1 &&
            lastDotPosition >= firstQuotationSignPosition
        ) {
            throw "Malformed URL. Cannot determine stream type.";
        }
        // If we have found a dot, try to extract the extension.
        if (lastDotPosition > -1) {
            if (firstQuotationSignPosition > -1) {
                var extension = streamUrl.slice(
                    lastDotPosition + 1,
                    firstQuotationSignPosition - 1
                );
            } else {
                var extension = streamUrl.slice(lastDotPosition + 1);
            }
            if (extension.toLowerCase() == "m3u8") {
                return "hls";
            }
        }
        // By default, 'mjpg' is the stream type.
        return "mjpg";
            var streamType = self.webcam_streamType();
            if (streamType == "mjpg") {
            } else if (streamType == "hls") {
            } else if (isWebRTCAvailable() && streamType == "webrtc") {
<div class="control-group" title="{{ _('URL to embed into the UI for live viewing of the webcam stream')|edq }}">
            <button class="btn" type="button" data-bind="click: testWebcamStreamUrl, enable: webcam_streamUrl() && !testWebcamStreamUrlBusy(), css: {disabled: !webcam_streamUrl() || testWebcamStreamUrlBusy()}"><i class="fas fa-spinner fa-spin" data-bind="visible: testWebcamStreamUrlBusy"></i> {{ _('Test') }}</button>
Fixed code: var getExternalHostUrl = function () {
    var loc = window.location;
    var port = "";
    if (
        (loc.protocol === "http:" && loc.port !== "80") ||
        (loc.protocol === "https:" && loc.port !== "443")
    ) {
        port = ":" + loc.port;
    }
    return loc.protocol + "//" + loc.hostname + port;
};
var validateWebcamUrl = function (streamUrl) {
    if (!streamUrl) {
        return false;
    }
    var lower = streamUrl.toLowerCase();
    var toParse = streamUrl;

    if (lower.startsWith("//")) {
        // protocol relative
        toParse = window.location.protocol + streamUrl;
    } else if (lower.startsWith("/")) {
        // host relative
        toParse = getExternalHostUrl() + streamUrl;
    } else if (
        lower.startsWith("http:") ||
        lower.startsWith("https:") ||
        lower.startsWith("webrtc:")
    ) {
        // absolute & http/https/webrtc
        toParse = streamUrl;
        return false;
    }

    try {
        return new URL(toParse);
    } catch (e) {
        return false;
    }
};

var determineWebcamStreamType = function (streamUrl) {
    if (!streamUrl) {

    var parsed = validateWebcamUrl(streamUrl);
    if (!parsed) {
        throw "Invalid streamUrl. Cannot determine stream type.";
    }

    if (parsed.protocol === "webrtc:") {
        return "webrtc";
    }

    var lastDotPosition = parsed.pathname.lastIndexOf(".");
    if (lastDotPosition !== -1) {
        var extension = parsed.pathname.substring(lastDotPosition + 1);
        if (extension.toLowerCase() === "m3u8") {
            return "hls";
        }
    }

    // By default, 'mjpg' is the stream type.
    return "mjpg";
        self.webcam_streamValid = ko.pureComputed(function () {
            var url = self.webcam_streamUrl();
            return !url || validateWebcamUrl(url);
        });

            var streamType;
            try {
                streamType = self.webcam_streamType();
            } catch (e) {
                streamType = "";
            }

            if (streamType === "mjpg") {
            } else if (streamType === "hls") {
            } else if (isWebRTCAvailable() && streamType === "webrtc") {
<div class="control-group" title="{{ _('URL to embed into the UI for live viewing of the webcam stream')|edq }}" data-bind="css: { error: !webcam_streamValid() }">
            <button class="btn" type="button" data-bind="click: testWebcamStreamUrl, enable: !testWebcamStreamUrlBusy() && webcam_streamValid(), css: {disabled: testWebcamStreamUrlBusy() || !webcam_streamValid()}"><i class="fas fa-spinner fa-spin" data-bind="visible: testWebcamStreamUrlBusy"></i> {{ _('Test') }}</button>