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:
parent
4c75bb3d38
commit
2f20ba8148
@ -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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 }[]>([])
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user