fix: Phase 5 — XSS sanitization, cookie security, redirect validation, input trimming

- BootScreen + Settings: v-html now uses DOMPurify.sanitize() for SVG content
- FileBrowser cookie: added Secure flag and 24h expiration
- TOTP secret: hidden by default with reveal toggle button
- Login redirect: validates URL is local-origin before redirecting
- Auth fields: password inputs trimmed before submission
- Route params: appId validated against safe pattern, invalid IDs redirect to /apps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-18 00:55:00 +00:00
parent b1e54e3626
commit d9b4478512
10 changed files with 84 additions and 24 deletions

View File

@ -378,7 +378,7 @@
> into the page (XSS), steal login cookies, or redirect you to a fake site after login. We fix all > into the page (XSS), steal login cookies, or redirect you to a fake site after login. We fix all
> of these and add proper input sanitization everywhere. > of these and add proper input sanitization everywhere.
- [ ] **Fix v-html XSS in BootScreen and Settings**: In `neode-ui/src/components/BootScreen.vue` line 55, replace `v-html="icons[currentIcon]"` with a safe rendering approach: - [x] **Fix v-html XSS in BootScreen and Settings**: In `neode-ui/src/components/BootScreen.vue` line 55, replace `v-html="icons[currentIcon]"` with a safe rendering approach:
1. Since the icons are hardcoded SVG strings, create a computed property that returns the current icon and use `v-html` with a DOMPurify sanitizer. 1. Since the icons are hardcoded SVG strings, create a computed property that returns the current icon and use `v-html` with a DOMPurify sanitizer.
2. Install DOMPurify: `cd neode-ui && npm install dompurify && npm install -D @types/dompurify`. 2. Install DOMPurify: `cd neode-ui && npm install dompurify && npm install -D @types/dompurify`.
3. Verify the package exists first: `npm view dompurify version`. 3. Verify the package exists first: `npm view dompurify version`.
@ -395,7 +395,7 @@
6. Run `npm run type-check` to verify. 6. Run `npm run type-check` to verify.
7. Build and deploy. Verify boot screen animation still works. Verify TOTP QR code still renders on Settings page. 7. Build and deploy. Verify boot screen animation still works. Verify TOTP QR code still renders on Settings page.
- [ ] **Fix FileBrowser cookie security flags**: In `neode-ui/src/api/filebrowser-client.ts` line 62, find `document.cookie = \`auth=${this.token}; path=/app/filebrowser; SameSite=Strict\``. This cookie is missing security flags. Since we can't set `HttpOnly` from JavaScript (that's a server-side flag), the best we can do client-side is: - [x] **Fix FileBrowser cookie security flags**: In `neode-ui/src/api/filebrowser-client.ts` line 62, find `document.cookie = \`auth=${this.token}; path=/app/filebrowser; SameSite=Strict\``. This cookie is missing security flags. Since we can't set `HttpOnly` from JavaScript (that's a server-side flag), the best we can do client-side is:
```typescript ```typescript
document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict; Secure` document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict; Secure`
``` ```
@ -407,7 +407,7 @@
``` ```
Build and deploy. Verify FileBrowser still works (login, browse, download). Build and deploy. Verify FileBrowser still works (login, browse, download).
- [ ] **Hide TOTP secret by default**: In `neode-ui/src/views/Settings.vue`, find line 289 with `{{ totpSecretBase32 }}`. Wrap it in a reveal toggle: - [x] **Hide TOTP secret by default**: In `neode-ui/src/views/Settings.vue`, find line 289 with `{{ totpSecretBase32 }}`. Wrap it in a reveal toggle:
1. Add a ref: `const showTotpSecret = ref(false)` 1. Add a ref: `const showTotpSecret = ref(false)`
2. Replace the display with: 2. Replace the display with:
```vue ```vue
@ -425,7 +425,7 @@
3. Remove the `select-all` class — users should deliberately copy, not accidentally select. 3. Remove the `select-all` class — users should deliberately copy, not accidentally select.
Build and deploy. Verify TOTP setup flow still works. Build and deploy. Verify TOTP setup flow still works.
- [ ] **Validate redirect URL after login**: In `neode-ui/src/router/index.ts`, find line 231 with `const redirectTo = (to.query.redirect as string) || '/dashboard'`. Replace with: - [x] **Validate redirect URL after login**: In `neode-ui/src/router/index.ts`, find line 231 with `const redirectTo = (to.query.redirect as string) || '/dashboard'`. Replace with:
```typescript ```typescript
function isLocalRedirect(path: unknown): path is string { function isLocalRedirect(path: unknown): path is string {
if (typeof path !== 'string') return false if (typeof path !== 'string') return false
@ -443,13 +443,13 @@
``` ```
Run `npm run type-check`. Build and deploy. Test: visit `http://192.168.1.198/login?redirect=https://evil.com` — after login should go to `/dashboard`, NOT `evil.com`. Visit `http://192.168.1.198/login?redirect=/mesh` — after login should go to `/mesh`. Run `npm run type-check`. Build and deploy. Test: visit `http://192.168.1.198/login?redirect=https://evil.com` — after login should go to `/dashboard`, NOT `evil.com`. Visit `http://192.168.1.198/login?redirect=/mesh` — after login should go to `/mesh`.
- [ ] **Add input trimming to all auth fields**: In `neode-ui/src/views/Login.vue`, find all password and input submissions. Add `.trim()` before sending: - [x] **Add input trimming to all auth fields**: In `neode-ui/src/views/Login.vue`, find all password and input submissions. Add `.trim()` before sending:
1. Search for `password.value` in the file. Wherever it's submitted via RPC (e.g., `params: { password: password.value }`), change to `params: { password: password.value.trim() }`. 1. Search for `password.value` in the file. Wherever it's submitted via RPC (e.g., `params: { password: password.value }`), change to `params: { password: password.value.trim() }`.
2. Do the same for TOTP code inputs, setup passwords, confirm passwords. 2. Do the same for TOTP code inputs, setup passwords, confirm passwords.
3. Also check `neode-ui/src/views/Settings.vue` for password change forms — trim those too. 3. Also check `neode-ui/src/views/Settings.vue` for password change forms — trim those too.
Run `npm run type-check`. Build and deploy. Test login with a password that has trailing spaces — should still work. Run `npm run type-check`. Build and deploy. Test login with a password that has trailing spaces — should still work.
- [ ] **Validate route parameters**: In `neode-ui/src/views/AppDetails.vue` (line ~485) and `neode-ui/src/views/AppSession.vue` (line ~267), add app ID validation: - [x] **Validate route parameters**: In `neode-ui/src/views/AppDetails.vue` (line ~485) and `neode-ui/src/views/AppSession.vue` (line ~267), add app ID validation:
1. Create a utility function in `neode-ui/src/utils/` or inline: 1. Create a utility function in `neode-ui/src/utils/` or inline:
```typescript ```typescript
function isValidAppId(id: unknown): id is string { function isValidAppId(id: unknown): id is string {
@ -469,7 +469,7 @@
``` ```
Build and deploy. Test: navigate to a valid app — should work. Navigate to `/app/../../etc/passwd` — should redirect to `/apps`. Build and deploy. Test: navigate to a valid app — should work. Navigate to `/app/../../etc/passwd` — should redirect to `/apps`.
- [ ] **Verify Phase 5 — Frontend hardened**: Run these checks: - [x] **Verify Phase 5 — Frontend hardened**: Run these checks:
1. `grep -rn "v-html" neode-ui/src/ --include="*.vue" | grep -v "DOMPurify\|sanitize"` — any remaining v-html should be justified. 1. `grep -rn "v-html" neode-ui/src/ --include="*.vue" | grep -v "DOMPurify\|sanitize"` — any remaining v-html should be justified.
2. `grep -rn "select-all" neode-ui/src/ --include="*.vue"` — TOTP secret should NOT have select-all. 2. `grep -rn "select-all" neode-ui/src/ --include="*.vue"` — TOTP secret should NOT have select-all.
3. `npm run type-check` — zero errors. 3. `npm run type-check` — zero errors.

View File

@ -1,14 +1,16 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.1.0", "version": "1.2.0-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.1.0", "version": "1.2.0-alpha",
"dependencies": { "dependencies": {
"@types/dompurify": "^3.0.5",
"d3": "^7.9.0", "d3": "^7.9.0",
"dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1", "fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
@ -3809,6 +3811,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -3854,7 +3865,6 @@
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vite-pwa/assets-generator": { "node_modules/@vite-pwa/assets-generator": {
@ -6194,6 +6204,15 @@
"node": ">= 8.0" "node": ">= 8.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@ -24,7 +24,9 @@
"generate-welcome-speech": "node scripts/generate-welcome-speech.js" "generate-welcome-speech": "node scripts/generate-welcome-speech.js"
}, },
"dependencies": { "dependencies": {
"@types/dompurify": "^3.0.5",
"d3": "^7.9.0", "d3": "^7.9.0",
"dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1", "fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",

View File

@ -59,7 +59,8 @@ class FileBrowserClient {
// FileBrowser returns the JWT as a plain string (possibly quoted) // FileBrowser returns the JWT as a plain string (possibly quoted)
this.token = text.replace(/^"|"$/g, '') this.token = text.replace(/^"|"$/g, '')
// Store token as cookie for img/video/audio src requests (avoids token in URL) // Store token as cookie for img/video/audio src requests (avoids token in URL)
document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict` const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString()
document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict; Secure; expires=${expires}`
return true return true
} catch { } catch {
return false return false

View File

@ -52,7 +52,7 @@
<div class="boot-icon-inner"> <div class="boot-icon-inner">
<Transition name="icon-morph" mode="out-in"> <Transition name="icon-morph" mode="out-in">
<div v-if="!bootDone" :key="currentIcon" class="boot-pixel-wrap" :class="{ 'boot-glitch': glitching }"> <div v-if="!bootDone" :key="currentIcon" class="boot-pixel-wrap" :class="{ 'boot-glitch': glitching }">
<div v-html="icons[currentIcon]" /> <div v-html="sanitizedIcon" />
</div> </div>
<div v-else key="logo" class="boot-logo-inner-logo"> <div v-else key="logo" class="boot-logo-inner-logo">
<AnimatedLogo size="xl" no-border fit /> <AnimatedLogo size="xl" no-border fit />
@ -70,7 +70,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import DOMPurify from 'dompurify'
import AnimatedLogo from '@/components/AnimatedLogo.vue' import AnimatedLogo from '@/components/AnimatedLogo.vue'
const props = defineProps<{ visible: boolean }>() const props = defineProps<{ visible: boolean }>()
@ -158,6 +159,8 @@ const bootMessages = [
{ delay: 23500, prefix: '***', text: 'ALL SYSTEMS OPERATIONAL', type: 'ready' }, { delay: 23500, prefix: '***', text: 'ALL SYSTEMS OPERATIONAL', type: 'ready' },
] ]
const sanitizedIcon = computed(() => DOMPurify.sanitize(icons[currentIcon.value] || '', { USE_PROFILES: { svg: true } }))
// Starfield // Starfield
let animFrame = 0 let animFrame = 0
const stars: { x: number; y: number; z: number }[] = [] const stars: { x: number; y: number; z: number }[] = []

View File

@ -218,6 +218,17 @@ async function checkSessionWithTimeout(store: ReturnType<typeof useAppStore>): P
* Navigation Guard * Navigation Guard
* Handles authentication and onboarding flow routing * Handles authentication and onboarding flow routing
*/ */
function isLocalRedirect(path: unknown): path is string {
if (typeof path !== 'string') return false
try {
if (path.startsWith('//') || path.includes('://')) return false
const url = new URL(path, window.location.origin)
return url.origin === window.location.origin
} catch {
return false
}
}
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
const store = useAppStore() const store = useAppStore()
const isPublic = to.meta.public const isPublic = to.meta.public
@ -228,7 +239,8 @@ router.beforeEach(async (to, _from, next) => {
// This prevents endless spinner on mobile when checkSession hangs (slow/unreachable network). // This prevents endless spinner on mobile when checkSession hangs (slow/unreachable network).
if (to.path === '/login' && store.isAuthenticated) { if (to.path === '/login' && store.isAuthenticated) {
// Redirect back to intended page (from ?redirect= query) or default to home // Redirect back to intended page (from ?redirect= query) or default to home
const redirectTo = (to.query.redirect as string) || '/dashboard' const rawRedirect = to.query.redirect
const redirectTo = isLocalRedirect(rawRedirect) ? rawRedirect : '/dashboard'
if (store.needsSessionValidation()) { if (store.needsSessionValidation()) {
next() next()
checkSessionWithTimeout(store).then((valid) => { checkSessionWithTimeout(store).then((valid) => {

View File

@ -482,7 +482,14 @@ const route = useRoute()
const store = useAppStore() const store = useAppStore()
const { t } = useI18n() const { t } = useI18n()
const appId = computed(() => route.params.id as string) const appId = computed(() => {
const id = route.params.id
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
router.replace('/apps')
return ''
}
return id
})
// Web-only app detection (no container external websites) // Web-only app detection (no container external websites)
const WEB_ONLY_APP_URLS: Record<string, string> = { const WEB_ONLY_APP_URLS: Record<string, string> = {

View File

@ -264,7 +264,14 @@ const panelClasses = computed(() => {
return `${base} app-session-overlay` return `${base} app-session-overlay`
}) })
const appId = computed(() => props.appIdProp || (route.params.appId as string)) const appId = computed(() => {
const id = props.appIdProp || (route.params.appId as string)
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || id.length > 64) {
router.replace('/apps')
return ''
}
return id
})
/** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */ /** Container apps: direct port access (avoids root-relative asset breakage under /app/xxx/ proxy) */
const APP_PORTS: Record<string, number> = { const APP_PORTS: Record<string, number> = {

View File

@ -372,14 +372,14 @@ async function handleSetup() {
try { try {
await rpcClient.call({ await rpcClient.call({
method: 'auth.setup', method: 'auth.setup',
params: { password: password.value } params: { password: password.value.trim() }
}) })
stopSynthwave() stopSynthwave()
whooshAway.value = true whooshAway.value = true
playLoginSuccessWhoosh() playLoginSuccessWhoosh()
loginTransition.setJustLoggedIn(true) loginTransition.setJustLoggedIn(true)
await store.login(password.value) await store.login(password.value.trim())
await new Promise(r => setTimeout(r, 520)) await new Promise(r => setTimeout(r, 520))
await router.replace(loginRedirectTo.value).catch(() => { await router.replace(loginRedirectTo.value).catch(() => {
window.location.href = loginRedirectTo.value window.location.href = loginRedirectTo.value
@ -412,7 +412,7 @@ async function handleLogin() {
error.value = null error.value = null
try { try {
const result = await store.login(password.value) const result = await store.login(password.value.trim())
if (result?.requires_totp) { if (result?.requires_totp) {
requiresTotp.value = true requiresTotp.value = true
loading.value = false loading.value = false

View File

@ -283,10 +283,16 @@
<template v-else-if="totpSetupStep === 2"> <template v-else-if="totpSetupStep === 2">
<h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.scanQrCode') }}</h3> <h3 class="text-lg font-semibold text-white mb-2">{{ t('settings.scanQrCode') }}</h3>
<p class="text-white/60 text-sm mb-4">{{ t('settings.scanQrInstruction') }}</p> <p class="text-white/60 text-sm mb-4">{{ t('settings.scanQrInstruction') }}</p>
<div class="flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html="totpQrSvg" /> <div class="flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html="sanitizedQrSvg" />
<div class="bg-black/30 rounded-lg px-3 py-2 mb-4"> <div v-if="totpSecretBase32" class="bg-black/30 rounded-lg px-3 py-2 mb-4">
<p class="text-xs text-white/50 mb-1">{{ t('settings.manualEntryKey') }}</p> <p class="text-xs text-white/50 mb-1">Manual entry key (keep secret!):</p>
<p class="text-sm font-mono text-orange-400 break-all select-all">{{ totpSecretBase32 }}</p> <div v-if="showTotpSecret" class="flex items-center gap-2">
<p class="text-sm font-mono text-orange-400 break-all">{{ totpSecretBase32 }}</p>
<button type="button" class="glass-button text-xs px-2 py-1" @click="showTotpSecret = false">Hide</button>
</div>
<button v-else type="button" class="glass-button text-xs px-3 py-1" @click="showTotpSecret = true">
Show manual entry key
</button>
</div> </div>
<form @submit.prevent="confirmTotpSetup" class="space-y-4"> <form @submit.prevent="confirmTotpSetup" class="space-y-4">
<input <input
@ -859,6 +865,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, nextTick } from 'vue' import { computed, ref, onMounted, nextTick } from 'vue'
import DOMPurify from 'dompurify'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { SUPPORTED_LOCALES, setLocale, type SupportedLocale } from '@/i18n' import { SUPPORTED_LOCALES, setLocale, type SupportedLocale } from '@/i18n'
@ -1018,7 +1025,9 @@ const totpSetupCode = ref('')
const totpSetupError = ref('') const totpSetupError = ref('')
const totpSetupLoading = ref(false) const totpSetupLoading = ref(false)
const totpQrSvg = ref('') const totpQrSvg = ref('')
const sanitizedQrSvg = computed(() => DOMPurify.sanitize(totpQrSvg.value, { USE_PROFILES: { svg: true } }))
const totpSecretBase32 = ref('') const totpSecretBase32 = ref('')
const showTotpSecret = ref(false)
const totpPendingToken = ref('') const totpPendingToken = ref('')
const totpBackupCodes = ref<string[]>([]) const totpBackupCodes = ref<string[]>([])
const backupCodesCopied = ref(false) const backupCodesCopied = ref(false)