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

@@ -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">