fix(ui): sanitize WireGuard QR SVG, guard mesh poll interval, log catalog fetch failures

Server.vue rendered the backend-generated WireGuard peer QR with raw
v-html while the analogous TOTP QR was DOMPurify-sanitized — both now
use the same svg-profile sanitizer. Mesh.vue's 5s poll interval gets
the same start-guard as the arch poll (no leak on double-mount) and is
nulled on unmount. curatedApps.ts catalog fetches no longer fail
silently: each failed source logs a console.warn, including the final
all-sources-failed fallback to the hardcoded list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-04 09:02:00 -04:00
parent 4c75bb3d38
commit 2f20ba8148
3 changed files with 26 additions and 12 deletions

View File

@ -388,13 +388,15 @@ onMounted(async () => {
if (!archPollInterval) { if (!archPollInterval) {
archPollInterval = setInterval(loadArchMessages, 15000) archPollInterval = setInterval(loadArchMessages, 15000)
} }
pollInterval = setInterval(() => { if (!pollInterval) {
mesh.fetchStatus() pollInterval = setInterval(() => {
mesh.fetchPeers() mesh.fetchStatus()
mesh.fetchMessages() mesh.fetchPeers()
mesh.fetchDeadmanStatus() mesh.fetchMessages()
mesh.fetchBlockHeaders() mesh.fetchDeadmanStatus()
}, 5000) mesh.fetchBlockHeaders()
}, 5000)
}
// Instant peer updates (#48): the backend nudges the data-model revision when // Instant peer updates (#48): the backend nudges the data-model revision when
// it discovers/updates a mesh peer, so refetch peers on the WS push rather // it discovers/updates a mesh peer, so refetch peers on the WS push rather
@ -414,7 +416,7 @@ onUnmounted(() => {
window.visualViewport.removeEventListener('scroll', updateKeyboardInset) window.visualViewport.removeEventListener('scroll', updateKeyboardInset)
} }
document.documentElement.style.removeProperty('--keyboard-inset') document.documentElement.style.removeProperty('--keyboard-inset')
if (pollInterval) clearInterval(pollInterval) if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null } if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
if (wsUnsub) { wsUnsub(); wsUnsub = null } if (wsUnsub) { wsUnsub(); wsUnsub = null }
}) })

View File

@ -306,7 +306,7 @@
</div> </div>
<!-- Existing peer QR view --> <!-- Existing peer QR view -->
<div v-else-if="peerQrData && !showingNewDevice" class="text-center"> <div v-else-if="peerQrData && !showingNewDevice" class="text-center">
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="peerQrData.qr_svg"></div> <div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="sanitizedPeerQrSvg"></div>
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p> <p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p> <p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
<div class="flex gap-2"> <div class="flex gap-2">
@ -318,7 +318,7 @@
<div v-else> <div v-else>
<div v-if="peerQrData"> <div v-if="peerQrData">
<div class="text-center"> <div class="text-center">
<div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="peerQrData.qr_svg"></div> <div class="bg-white rounded-xl p-4 mb-4 inline-block" v-html="sanitizedPeerQrSvg"></div>
<p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p> <p class="text-sm text-white/70 mb-2">Scan with the <strong>WireGuard</strong> app</p>
<p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p> <p class="text-xs text-white/40 font-mono mb-4">{{ peerQrData.peer_ip }}</p>
<div class="flex gap-2"> <div class="flex gap-2">
@ -406,6 +406,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import DOMPurify from 'dompurify'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import QuickActionsCard from './server/QuickActionsCard.vue' import QuickActionsCard from './server/QuickActionsCard.vue'
@ -499,6 +500,11 @@ const showAddDeviceModal = ref(false)
const newPeerName = ref('') const newPeerName = ref('')
const creatingPeer = ref(false) const creatingPeer = ref(false)
const peerQrData = ref<{ qr_svg: string; config: string; peer_ip: string } | null>(null) const peerQrData = ref<{ qr_svg: string; config: string; peer_ip: string } | null>(null)
// Sanitize like TwoFactorSection's TOTP QR the SVG is backend-generated,
// but v-html without a sanitizer is one compromised RPC away from XSS.
const sanitizedPeerQrSvg = computed(() =>
DOMPurify.sanitize(peerQrData.value?.qr_svg ?? '', { USE_PROFILES: { svg: true } }),
)
const peerError = ref('') const peerError = ref('')
const copiedConfig = ref(false) const copiedConfig = ref(false)
const vpnPeers = ref<{ name: string; ip: string; type?: string; npub?: string }[]>([]) const vpnPeers = ref<{ name: string; ip: string; type?: string; npub?: string }[]>([])

View File

@ -57,7 +57,10 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
// Cache in localStorage for offline fallback // Cache in localStorage for offline fallback
try { localStorage.setItem('archy_catalog', JSON.stringify(data)) } catch {} try { localStorage.setItem('archy_catalog', JSON.stringify(data)) } catch {}
return data return data
} catch { continue } } catch (e) {
console.warn(`[catalog] fetch failed for ${url}:`, e)
continue
}
} }
// Try localStorage cache as final fallback // Try localStorage cache as final fallback
@ -68,8 +71,11 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
catalogFetchedAt = Date.now() - CATALOG_TTL + 5 * 60 * 1000 // re-check in 5 min catalogFetchedAt = Date.now() - CATALOG_TTL + 5 * 60 * 1000 // re-check in 5 min
return cachedCatalog return cachedCatalog
} }
} catch {} } catch (e) {
console.warn('[catalog] localStorage fallback unreadable:', e)
}
console.warn('[catalog] all sources failed — using hardcoded app list')
return null return null
} }