fix(idaa): close Jitsi fake-UUID access hole + add breakout modal

Security fixes (3 layers):
1. layout: verify_novi_uuid now rejects Novi 200 responses with no member
   data — prevents non-existent UUIDs from passing as verified members
2. layout: access gate now requires $idaa_loc.novi_verified in addition to
   novi_uuid (stale UUID alone was insufficient)
3. video_conferences: onMount guard aborts Jitsi init if the layout-verified
   UUID doesn't match the URL UUID (defense-in-depth)

Also fixes an infinite verification loop: when verification fails, writes to
$idaa_loc trigger storage events that cause $ae_loc to re-notify subscribers,
re-running Effect 2 indefinitely. Added verify_failed latch to stop retries —
the UUID is fixed for the page lifetime, retrying always produces the same result.

Feature: "Open Externally" button + modal (iframe mode only) lets IDAA members
escape the Novi iframe when scrolling/layout is broken. Options: copy link to
clipboard or open in new tab. Accessible to all users without edit-mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-30 19:15:21 -04:00
parent 702a7a73de
commit 6559e3393c
2 changed files with 130 additions and 1 deletions

View File

@@ -53,6 +53,17 @@ let meeting_duration: string = $state('00:00:00');
let duration_timer_id: any = $state(null);
// reporting_timer_id is now removed
// Breakout modal — lets users open the meeting outside the Novi iframe
let show_breakout_modal: boolean = $state(false);
let breakout_link_copied: boolean = $state(false);
function copy_meeting_link() {
navigator.clipboard.writeText($page.url.href).then(() => {
breakout_link_copied = true;
setTimeout(() => (breakout_link_copied = false), 2000);
});
}
/**
* Creates a new activity log entry for a discrete event (e.g., raise hand).
*/
@@ -567,6 +578,21 @@ onMount(async () => {
await fetch_novi_data();
// Defense-in-depth: the parent layout should have blocked unverified users,
// but guard here as a second layer. For non-trusted users, the UUID in the URL
// must match the UUID that was verified by the layout. Prevents joining Jitsi
// with a fake UUID even if the layout guard is somehow bypassed.
if (!$ae_loc.trusted_access && (!$idaa_loc.novi_verified || $idaa_loc.novi_uuid !== user_id)) {
const container = document.getElementById(jitsi_container_id);
if (container)
container.innerHTML =
'<p style="padding:1rem;color:red;font-weight:bold;">Access denied: Novi identity not verified for this meeting.</p>';
console.error(
`Jitsi: Aborting — UUID not verified or mismatch. verified=${$idaa_loc.novi_verified}, stored=${$idaa_loc.novi_uuid}, url=${user_id}`
);
return;
}
if (!domain) {
console.error('Jitsi: domain not set after fetch_novi_data — cannot load Jitsi script.');
return;
@@ -1182,6 +1208,81 @@ async function init_jitsi() {
</div>
{/if}
<!-- Breakout button: only shown in iframe mode — pointless outside an iframe since the user is
already in a full tab. WHY: Novi iframes squish the layout and scrolling is unreliable.
Members need a way to escape to a proper full-tab context. -->
{#if $ae_loc.iframe}
<div class="fixed bottom-0.5 left-8 z-20 print:hidden">
<button
type="button"
onclick={() => (show_breakout_modal = true)}
class="flex items-center gap-2 rounded-lg border border-gray-300 bg-white/90 px-3 py-2 mb-2 text-sm shadow-md backdrop-blur-sm hover:bg-white"
title="Open this meeting outside the Novi iframe">
<span class="fas fa-external-link-alt" aria-hidden="true"></span>
Open Externally
</button>
</div>
{/if}
{#if show_breakout_modal}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 print:hidden"
onclick={() => (show_breakout_modal = false)}
onkeydown={(e) => e.key === 'Escape' && (show_breakout_modal = false)}
role="button"
tabindex="-1"
aria-label="Close meeting link dialog">
<div
class="mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="breakout_modal_title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}>
<div class="mb-4 flex items-start justify-between">
<div>
<h2 id="breakout_modal_title" class="text-lg font-bold">
Open Meeting Externally
</h2>
<p class="mt-1 text-sm text-gray-500">
Open this meeting outside the embedded Novi iframe.
</p>
</div>
<button
type="button"
onclick={() => (show_breakout_modal = false)}
class="ml-4 shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Close">
<span class="fas fa-times" aria-hidden="true"></span>
</button>
</div>
<div class="flex flex-col gap-2">
<button
type="button"
onclick={copy_meeting_link}
class="flex items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors {breakout_link_copied
? 'border-green-300 bg-green-50 text-green-700'
: 'border-gray-300 bg-white hover:bg-gray-50'}">
<span
class="fas {breakout_link_copied ? 'fa-check' : 'fa-copy'}"
aria-hidden="true"></span>
{breakout_link_copied ? 'Copied!' : 'Copy Link'}
</button>
<a
href={$page.url.href}
target="_blank"
rel="noopener noreferrer"
onclick={() => (show_breakout_modal = false)}
class="flex items-center justify-center gap-2 rounded-lg border border-indigo-300 bg-indigo-50 px-4 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-100">
<span class="fas fa-external-link-alt" aria-hidden="true"></span>
Open in New Tab
</a>
</div>
</div>
</div>
{/if}
<style>
.jitsi-container {
height: 100vh;
@@ -1190,6 +1291,7 @@ async function init_jitsi() {
top: 0;
left: 0;
overflow: hidden;
padding-bottom: 3em; /* Space for the tools button */
}
.jitsi-tools {