From 8192aedd69beb190233817d364480540e15fecfc Mon Sep 17 00:00:00 2001 From: Blake Erickson Date: Fri, 10 Jan 2025 11:07:41 -0700 Subject: [PATCH] SECURITY: Sanitize video placeholder urls Make sure video placeholder urls are valid. An error message is displayed instead of an infinite loading spinner after clicking play. --- .../video-placeholder.js | 61 ++++++++++++++++++- .../acceptance/video-placeholder-test.js | 26 ++++++++ config/locales/client.en.yml | 1 + 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/app/instance-initializers/video-placeholder.js b/app/assets/javascripts/discourse/app/instance-initializers/video-placeholder.js index 9deb040a700..3d14304ac5e 100644 --- a/app/assets/javascripts/discourse/app/instance-initializers/video-placeholder.js +++ b/app/assets/javascripts/discourse/app/instance-initializers/video-placeholder.js @@ -1,5 +1,6 @@ import { spinnerHTML } from "discourse/helpers/loading-spinner"; import { withPluginApi } from "discourse/lib/plugin-api"; +import { sanitize } from "discourse/lib/text"; import { iconHTML } from "discourse-common/lib/icon-library"; import discourseLater from "discourse-common/lib/later"; import I18n from "discourse-i18n"; @@ -15,10 +16,39 @@ export default { parentDiv.style.cursor = ""; overlay.innerHTML = spinnerHTML; + const videoSrc = sanitizeUrl(parentDiv.dataset.videoSrc); + const origSrc = sanitizeUrl(parentDiv.dataset.origSrc); + const dataOrigSrcAttr = + origSrc !== null ? `data-orig-src="${origSrc}"` : ""; + + if (videoSrc === null) { + const existingNotice = wrapper.querySelector(".notice.error"); + if (existingNotice) { + existingNotice.remove(); + } + + const notice = document.createElement("div"); + notice.className = "notice error"; + notice.innerHTML = + iconHTML("triangle-exclamation") + + " " + + I18n.t("invalid_video_url"); + wrapper.appendChild(notice); + overlay.innerHTML = iconHTML("play"); + + parentDiv.style.cursor = "pointer"; + parentDiv.addEventListener( + "click", + (e) => handleVideoPlaceholderClick(helper, e), + { once: true } + ); + return; + } + const videoHTML = ` `; parentDiv.insertAdjacentHTML("beforeend", videoHTML); parentDiv.classList.add("video-container"); @@ -108,6 +138,33 @@ export default { }); } + function sanitizeUrl(url) { + try { + const parsedUrl = new URL(url, window.location.origin); + + if ( + ["http:", "https:"].includes(parsedUrl.protocol) || + url.startsWith("/") + ) { + const sanitized = sanitize(url); + + if ( + sanitized && + sanitized.trim() !== "" && + !sanitized.includes(">") && + !sanitized.includes("<") + ) { + return sanitized; + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.warn("Invalid URL encountered:", url, e.message); + } + + return null; + } + api.decorateCookedElement(applyVideoPlaceholder, { onlyStream: true, }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/video-placeholder-test.js b/app/assets/javascripts/discourse/tests/acceptance/video-placeholder-test.js index f518752444e..873af1ff884 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/video-placeholder-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/video-placeholder-test.js @@ -50,4 +50,30 @@ acceptance("Video Placeholder Test", function () { .hasStyle({ display: "block" }, "The video is no longer hidden"); assert.dom(".video-placeholder-wrapper").doesNotExist(); }); + + test("displays an error for invalid video URL and allows retry", async function (assert) { + await visit("/t/54081"); + + const placeholder = document.querySelector(".video-placeholder-container"); + placeholder.setAttribute( + "data-video-src", + 'http://example.com/video.mp4">' + ); + + await click(".video-placeholder-overlay"); + + assert + .dom(".video-placeholder-wrapper .notice.error") + .exists("An error message is displayed for an invalid URL"); + assert + .dom(".video-placeholder-wrapper .notice.error") + .hasText( + "This video cannot be played because the URL is invalid or unavailable.", + "Error message is correct" + ); + + assert + .dom("video") + .doesNotExist("No video element is created for invalid URL"); + }); }); diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 320af174189..1080114a57a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4360,6 +4360,7 @@ en: retry: "Retry loading the image" cannot_render_video: This video cannot be rendered because your browser does not support the codec. + invalid_video_url: This video cannot be played because the URL is invalid or unavailable. keyboard_shortcuts_help: shortcut_key_delimiter_comma: ", "