From 5a4eb5f8ca5716945471f7012163d4f1a0a65159 Mon Sep 17 00:00:00 2001 From: Flegma Date: Fri, 10 Jul 2026 21:58:48 +0200 Subject: [PATCH] fix: sanitize notification HTML, restrict stream link scheme, fix 3D replay + demo leaks - NotificationMessage: sanitize props.html with DOMPurify before parsing and rebuilding nodes, and render the SSR fallback as text instead of raw innerHTML. This was the only HTML sink in the app skipping DOMPurify, so a notification message embedding unescaped user text (team/player name) could reconstruct a javascript:/iframe/onerror XSS sink (#542). - StreamEmbed / MatchLiveStreams: reject non-http(s) stream links (z.url() accepts javascript:/data:). parseStreamLink and mountGenericIframe now scheme-guard before building the iframe (plus a sandbox attr on the generic embed), and openStream scheme-guards window.open and adds noopener (#541). - Replay3DLite: the pointerup/pointermove window listeners were anonymous, so cleanup could not remove them and their closures pinned the whole Three.js scene per remount. Named and removed on unmount (#550). - useDemoPlayback: the shared 1Hz poll timer, socket handler, and document visibilitychange listener were never torn down on unmount (notably dev attach mode, which skips stop()). Added ref-counted onScopeDispose cleanup that fires when the last consumer unmounts (#550). --- components/StreamEmbed.vue | 29 +++++++++++++++++- components/match/MatchLiveStreams.vue | 30 +++++++++++++++++-- components/match/Replay3DLite.vue | 17 +++++++---- .../notification/NotificationMessage.vue | 12 ++++++-- composables/useDemoPlayback.ts | 23 +++++++++++++- 5 files changed, 100 insertions(+), 11 deletions(-) diff --git a/components/StreamEmbed.vue b/components/StreamEmbed.vue index 538414a77..e1d9a8a5f 100644 --- a/components/StreamEmbed.vue +++ b/components/StreamEmbed.vue @@ -224,7 +224,18 @@ export default { platform: Platform; embedId: string | null; } { - const url = new URL(link); + let url: URL; + try { + url = new URL(link); + } catch { + // Malformed link: nothing to embed. + return { platform: "iframe", embedId: null }; + } + // Only http(s) may ever reach an iframe src. A javascript:/data: link + // would otherwise be embedded verbatim on a public match page. + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { platform: "iframe", embedId: null }; + } const hostname = url.hostname.toLowerCase(); if (hostname.endsWith("twitch.tv")) { @@ -392,6 +403,15 @@ export default { const playerRef = this.$refs.playerRef as HTMLDivElement | null; if (!playerRef || !url) return; + // Defense-in-depth alongside parseStreamLink: never embed a non-http(s) + // URL, even if a caller reaches this method with an unvetted value. + try { + const scheme = new URL(url).protocol; + if (scheme !== "http:" && scheme !== "https:") return; + } catch { + return; + } + this.cleanupPlayer(); const iframe = document.createElement("iframe"); @@ -400,6 +420,13 @@ export default { iframe.height = "100%"; iframe.allow = "autoplay; fullscreen; encrypted-media; picture-in-picture"; + // Constrain an arbitrary third-party embed: keep the player functional + // (scripts, its own origin, fullscreen/popups) but block it from + // navigating or driving the top-level 5stack window. + iframe.setAttribute( + "sandbox", + "allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation allow-forms", + ); iframe.style.aspectRatio = "16 / 9"; iframe.style.width = "100%"; iframe.style.height = "100%"; diff --git a/components/match/MatchLiveStreams.vue b/components/match/MatchLiveStreams.vue index 15b7ca1f3..b7072bb88 100644 --- a/components/match/MatchLiveStreams.vue +++ b/components/match/MatchLiveStreams.vue @@ -421,7 +421,23 @@ export default { form: useForm({ validationSchema: toTypedSchema( z.object({ - link: z.string().url(), + link: z + .string() + .url() + // z.url() accepts javascript:/data: URIs (they are valid URLs); + // restrict to http(s) so a stored link can never become a script + // sink in the embed iframe or window.open. + .refine( + (value) => { + try { + const protocol = new URL(value).protocol; + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } + }, + { message: "Link must be an http(s) URL" }, + ), title: z.string(), }), ), @@ -618,7 +634,17 @@ export default { } }, openStream(link) { - window.open(link, "_blank"); + // Guard the scheme even though input validation restricts it: stored + // rows predate the validation and could still hold a javascript: link. + try { + const protocol = new URL(link).protocol; + if (protocol !== "http:" && protocol !== "https:") { + return; + } + } catch { + return; + } + window.open(link, "_blank", "noopener,noreferrer"); }, openEditModal(stream) { // ensure add modal is closed to avoid shared form conflicts diff --git a/components/match/Replay3DLite.vue b/components/match/Replay3DLite.vue index ccdc865e6..8752d1e94 100644 --- a/components/match/Replay3DLite.vue +++ b/components/match/Replay3DLite.vue @@ -293,14 +293,17 @@ onMounted(() => { downY = e.clientY; } }); - addEventListener("pointerup", (e) => { - if ((e as PointerEvent).pointerType === "touch") return; // touch = OrbitControls + // Named so cleanup can remove them: these are on window (not el), so if they + // stayed anonymous they would outlive the component and their closures would + // pin the whole Three.js scene (camera/controls/renderer) on every remount. + const onPointerUp = (e: PointerEvent) => { + if (e.pointerType === "touch") return; // touch = OrbitControls el.style.cursor = "grab"; if (e.button !== 0) return; rl = false; if (Math.hypot(e.clientX - downX, e.clientY - downY) < 5) pickUtilLine(e); // click, not drag - }); - addEventListener("pointermove", (e) => { + }; + const onPointerMove = (e: PointerEvent) => { if (!rl) return; const dx = e.clientX - rlx, dy = e.clientY - rly; @@ -335,7 +338,9 @@ onMounted(() => { const pitch = Math.atan2(newOff.y, Math.hypot(newOff.x, newOff.z)); if (Math.abs(pitch) < 1.45) off.copy(newOff); controls.target.copy(camera.position).add(off); - }); + }; + addEventListener("pointerup", onPointerUp); + addEventListener("pointermove", onPointerMove); let dollyAccum = 0; el.addEventListener( "wheel", @@ -1746,6 +1751,8 @@ onMounted(() => { removeEventListener("keydown", onKeyDown); removeEventListener("keyup", onKeyUp); removeEventListener("blur", clearKeys); + removeEventListener("pointerup", onPointerUp); + removeEventListener("pointermove", onPointerMove); document.removeEventListener("visibilitychange", onVis); }; }); diff --git a/components/notification/NotificationMessage.vue b/components/notification/NotificationMessage.vue index 3718619ab..d5dbed5af 100644 --- a/components/notification/NotificationMessage.vue +++ b/components/notification/NotificationMessage.vue @@ -1,5 +1,6 @@