Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion components/StreamEmbed.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down Expand Up @@ -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");
Expand All @@ -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%";
Expand Down
30 changes: 28 additions & 2 deletions components/match/MatchLiveStreams.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}),
),
Expand Down Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions components/match/Replay3DLite.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1746,6 +1751,8 @@ onMounted(() => {
removeEventListener("keydown", onKeyDown);
removeEventListener("keyup", onKeyUp);
removeEventListener("blur", clearKeys);
removeEventListener("pointerup", onPointerUp);
removeEventListener("pointermove", onPointerMove);
document.removeEventListener("visibilitychange", onVis);
};
});
Expand Down
12 changes: 10 additions & 2 deletions components/notification/NotificationMessage.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { computed, defineComponent, h, resolveComponent } from "vue";
import DOMPurify from "dompurify";

function toInternalPath(href: string | undefined): string | null {
if (!href) return null;
Expand Down Expand Up @@ -58,15 +59,22 @@ export default defineComponent({
const parsed = computed(() => {
if (typeof window === "undefined") return null;
const container = document.createElement("div");
container.innerHTML = props.html;
// Sanitize before parsing: notification HTML is server-built and may
// embed user-controlled fields (team/player names, scheduling text).
// DOMPurify strips scripts, event-handler attributes, and javascript:
// URIs so the nodeToVNode rebuild below cannot reconstruct an XSS sink.
// Mirrors the news components, which sanitize the same way.
container.innerHTML = DOMPurify.sanitize(props.html);
return Array.from(container.childNodes)
.map((n) => nodeToVNode(n, NuxtLink))
.filter((c) => c !== null && c !== "");
});

return () => {
if (!parsed.value) {
return h("span", { innerHTML: props.html });
// SSR (no DOM to sanitize against): render as escaped text rather than
// raw innerHTML. The client re-renders the sanitized markup on hydrate.
return h("span", props.html);
}
return h("span", parsed.value);
};
Expand Down
23 changes: 22 additions & 1 deletion composables/useDemoPlayback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@ import { useNuxtApp } from "#app";
import getGraphqlClient from "~/graphql/getGraphqlClient";
import { useSubscriptionManager } from "~/composables/useSubscriptionManager";
import socket from "~/web-sockets/Socket";
import { getCurrentScope, onScopeDispose } from "vue";

// Module-level so multiple useDemoPlayback() callers share one poll
// loop instead of multiplying the request rate per mount.
let statePollTimer: ReturnType<typeof setInterval> | null = null;
let stateSocketHandler: ((data: any) => void) | null = null;
// The visibilitychange listener is also shared (module-level) so any caller's
// stopStatePoll fully tears it down; startStatePoll re-registers it once.
let visibilityHandler: (() => void) | null = null;
// Wall clock of the last pod-pushed state frame; gates the fallback poll.
let lastStatePushMs = 0;
// How many mounted callers currently hold this composable. The shared poll /
// socket / listener are only torn down when the last one unmounts, so a child
// component unmounting does not kill polling for a still-mounted parent.
let activeConsumers = 0;

type DemoSpecSlot = {
slot: number;
Expand Down Expand Up @@ -103,6 +111,20 @@ export function useDemoPlayback() {
const { $apollo } = useNuxtApp();
const { subscribe, unsubscribe } = useSubscriptionManager();

// Tie the shared poll/socket/listener lifetime to the mounted callers. Without
// this, unmounting a component that started playback (notably dev attach mode,
// which never routes through stop()) left the 1Hz timer, the socket handler,
// and the document visibilitychange listener running forever.
if (getCurrentScope()) {
activeConsumers++;
onScopeDispose(() => {
activeConsumers = Math.max(0, activeConsumers - 1);
if (activeConsumers === 0) {
stopStatePoll();
}
});
}

function subscriptionKey(sessionId: string) {
return `demo-session:${sessionId}`;
}
Expand All @@ -111,7 +133,6 @@ export function useDemoPlayback() {
// to this socket. The timer below is a fallback poll that only sends
// when pushes go quiet (e.g. an older pod image).
const PUSH_FRESH_MS = 3_500;
let visibilityHandler: (() => void) | null = null;
function tickOnce() {
if (!store.matchMapId) return;
if (Date.now() - lastStatePushMs < PUSH_FRESH_MS) return;
Expand Down