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
> 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.
2. Install DOMPurify: `cd neode-ui && npm install dompurify && npm install -D @types/dompurify`.
3. Verify the package exists first: `npm view dompurify version`.
@ -395,7 +395,7 @@
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.
- [ ] **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
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).
- [ ] **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)`
2. Replace the display with:
```vue
@ -425,7 +425,7 @@
3. Remove the `select-all` class — users should deliberately copy, not accidentally select.
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
function isLocalRedirect(path: unknown): path is string {
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`.
- [ ] **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() }`.
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.
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:
```typescript
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`.
- [ ] **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.
2. `grep -rn "select-all" neode-ui/src/ --include="*.vue"` — TOTP secret should NOT have select-all.
3. `npm run type-check` — zero errors.

View File

@ -1,14 +1,16 @@
{
"name": "neode-ui",
"version": "1.1.0",
"version": "1.2.0-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.1.0",
"version": "1.2.0-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"d3": "^7.9.0",
"dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0",
"pinia": "^3.0.4",
@ -3809,6 +3811,15 @@
"dev": true,
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -3854,7 +3865,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vite-pwa/assets-generator": {
@ -6194,6 +6204,15 @@
"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": {
"version": "1.0.1",
"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"
},
"dependencies": {
"@types/dompurify": "^3.0.5",
"d3": "^7.9.0",
"dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0",
"pinia": "^3.0.4",

View File

@ -59,7 +59,8 @@ class FileBrowserClient {
// FileBrowser returns the JWT as a plain string (possibly quoted)
this.token = text.replace(/^"|"$/g, '')
// 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
} catch {
return false

View File

@ -52,7 +52,7 @@
<div class="boot-icon-inner">
<Transition name="icon-morph" mode="out-in">
<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 v-else key="logo" class="boot-logo-inner-logo">
<AnimatedLogo size="xl" no-border fit />
@ -70,7 +70,8 @@
</template>
<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'
const props = defineProps<{ visible: boolean }>()
@ -158,6 +159,8 @@ const bootMessages = [
{ delay: 23500, prefix: '***', text: 'ALL SYSTEMS OPERATIONAL', type: 'ready' },
]
const sanitizedIcon = computed(() => DOMPurify.sanitize(icons[currentIcon.value] || '', { USE_PROFILES: { svg: true } }))
// Starfield
let animFrame = 0
const stars: { x: number; y: number; z: number }[] = []

View File

@ -218,6 +218,17 @@ async function checkSessionWithTimeout(store: ReturnType<typeof useAppStore>): P
* Navigation Guard
* 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) => {
const store = useAppStore()
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).
if (to.path === '/login' && store.isAuthenticated) {
// 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()) {
next()
checkSessionWithTimeout(store).then((valid) => {

View File

@ -482,7 +482,14 @@ const route = useRoute()
const store = useAppStore()
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)
const WEB_ONLY_APP_URLS: Record<string, string> = {

View File

@ -264,7 +264,14 @@ const panelClasses = computed(() => {
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) */
const APP_PORTS: Record<string, number> = {

View File

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

View File

@ -283,10 +283,16 @@
<template v-else-if="totpSetupStep === 2">
<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>
<div class="flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html="totpQrSvg" />
<div 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-sm font-mono text-orange-400 break-all select-all">{{ totpSecretBase32 }}</p>
<div class="flex justify-center mb-4 bg-white rounded-xl p-4 mx-auto w-fit" v-html="sanitizedQrSvg" />
<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">Manual entry key (keep secret!):</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>
<form @submit.prevent="confirmTotpSetup" class="space-y-4">
<input
@ -859,6 +865,7 @@
<script setup lang="ts">
import { computed, ref, onMounted, nextTick } from 'vue'
import DOMPurify from 'dompurify'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { SUPPORTED_LOCALES, setLocale, type SupportedLocale } from '@/i18n'
@ -1018,7 +1025,9 @@ const totpSetupCode = ref('')
const totpSetupError = ref('')
const totpSetupLoading = ref(false)
const totpQrSvg = ref('')
const sanitizedQrSvg = computed(() => DOMPurify.sanitize(totpQrSvg.value, { USE_PROFILES: { svg: true } }))
const totpSecretBase32 = ref('')
const showTotpSecret = ref(false)
const totpPendingToken = ref('')
const totpBackupCodes = ref<string[]>([])
const backupCodesCopied = ref(false)