diff --git a/components/StreamEmbed.vue b/components/StreamEmbed.vue index 538414a7..e1d9a8a5 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 15b7ca1f..b7072bb8 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 ccdc865e..8752d1e9 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 3718619a..d5dbed5a 100644 --- a/components/notification/NotificationMessage.vue +++ b/components/notification/NotificationMessage.vue @@ -1,5 +1,6 @@