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:
@@ -43,6 +43,14 @@ let novi_verifying: boolean = $state(!!url_uuid);
|
||||
// which would cause the guard to fire immediately and skip verification entirely.
|
||||
let verify_in_flight = false;
|
||||
|
||||
// Failure latch — set to true when verification definitively fails (e.g. Novi returns
|
||||
// 200 with empty data for a non-existent UUID, or a 4xx response).
|
||||
// WHY: Without this, writes to $idaa_loc in the catch block trigger svelte-persisted-store
|
||||
// storage events, which cause $ae_loc to re-notify its subscribers, re-running Effect 2 in
|
||||
// an infinite loop. The UUID is fixed for this page load (read once from window.location.search),
|
||||
// so retrying will always produce the same result. User must reload to retry.
|
||||
let verify_failed = false;
|
||||
|
||||
// Show a manual reset button if the spinner is still visible after this many ms.
|
||||
// Handles the case where site_cfg_json loads without novi_idaa_api_key (stale cache)
|
||||
// or the Novi API call hangs — the user would otherwise be stuck with no escape.
|
||||
@@ -99,6 +107,14 @@ $effect(() => {
|
||||
|
||||
if (verify_in_flight) return;
|
||||
|
||||
if (verify_failed) {
|
||||
// Verification definitively failed for this UUID — do not retry.
|
||||
// See comment on verify_failed declaration for the full explanation.
|
||||
if (log_lvl) console.log(`IDAA Layout: skipping re-verification — already failed for ${url_uuid}`);
|
||||
novi_verifying = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// TTL cache: skip if this UUID was recently verified.
|
||||
// Prevents duplicate API calls when site_cfg_json updates multiple times (SWR pattern).
|
||||
const now = Date.now();
|
||||
@@ -206,6 +222,16 @@ async function verify_novi_uuid(
|
||||
? result.Email.replace(/\s+/g, '+')
|
||||
: null;
|
||||
|
||||
// WHY: Novi may return HTTP 200 with empty/null fields for UUIDs that don't
|
||||
// exist or have been deleted (common API anti-pattern — empty 200 vs 404).
|
||||
// Without this check, any UUID would pass verification as long as Novi doesn't
|
||||
// return a 4xx status. Require at least one identity field to treat as a real member.
|
||||
if (!verified_email && !first_name && !(result?.Name)) {
|
||||
throw new Error(
|
||||
`Novi API returned 200 but no member data for UUID ${uuid} — treating as unverified`
|
||||
);
|
||||
}
|
||||
|
||||
$idaa_loc.novi_uuid = uuid;
|
||||
$idaa_loc.novi_email = verified_email;
|
||||
$idaa_loc.novi_full_name = verified_name;
|
||||
@@ -245,6 +271,7 @@ async function verify_novi_uuid(
|
||||
`IDAA Layout: Novi UUID verification failed for ${uuid}:`,
|
||||
error
|
||||
);
|
||||
verify_failed = true; // Latch — stop the Effect 2 retry loop. See declaration comment.
|
||||
$idaa_loc.novi_uuid = null;
|
||||
$idaa_loc.novi_email = null;
|
||||
$idaa_loc.novi_full_name = null;
|
||||
@@ -290,7 +317,7 @@ async function verify_novi_uuid(
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if $ae_loc.trusted_access || ($ae_loc.authenticated_access && $idaa_loc.novi_uuid)}
|
||||
{:else if $ae_loc.trusted_access || ($ae_loc.authenticated_access && $idaa_loc.novi_uuid && $idaa_loc.novi_verified)}
|
||||
{@render children?.()}
|
||||
{#if $idaa_loc.novi_uuid}
|
||||
<span class="text-sm text-gray-500">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user