diff --git a/Cargo.lock b/Cargo.lock index e245e9db3..4f2f758b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5254,6 +5254,7 @@ dependencies = [ "wiremock", "x25519-dalek", "xz2", + "zeroize", "zip 2.4.2", ] diff --git a/Cargo.toml b/Cargo.toml index 3f0d6891a..ce1f88bf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,10 @@ uuid = { version = "1", features = ["v4"] } anyhow = "1.0" async-trait = "0.1" chacha20poly1305 = "0.10" +# Wipe master keys / decrypted secret buffers from memory on drop (audit C9). +# Already present transitively (resolved to 1.8.x via the *-dalek / cipher +# crates); declared directly so the keyring module can `use zeroize::Zeroizing`. +zeroize = "1" x25519-dalek = { version = "2", features = ["static_secrets"] } hkdf = "0.12" hex = "0.4" diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 5ab3ea6d5..8c20324e0 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ } ], "security": { - "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.googletagmanager.com; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* http: ws://127.0.0.1:* ws://localhost:* ws: https: wss: data: blob: https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com; frame-src 'self' https: data: blob:" + "csp": "default-src 'self' 'unsafe-inline' data: blob: https: wss: ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:*; script-src 'self' 'wasm-unsafe-eval' https://www.googletagmanager.com; img-src 'self' data: blob: https:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* https: wss: data: blob: https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com; frame-src 'self' https: data: blob:" }, "macOSPrivateApi": true }, diff --git a/app/src/components/oauth/OAuthProviderButton.tsx b/app/src/components/oauth/OAuthProviderButton.tsx index 56e4b5f37..29799d717 100644 --- a/app/src/components/oauth/OAuthProviderButton.tsx +++ b/app/src/components/oauth/OAuthProviderButton.tsx @@ -10,7 +10,7 @@ import { } from '../../store/deepLinkAuthState'; import type { OAuthProviderConfig } from '../../types/oauth'; import { IS_DEV } from '../../utils/config'; -import { handleDeepLinkUrls } from '../../utils/desktopDeepLinkListener'; +import { handleDeepLinkUrls, registerAuthDeepLinkState } from '../../utils/desktopDeepLinkListener'; import { startLoopbackOauthListener } from '../../utils/loopbackOauthListener'; import { prepareOAuthLoginLaunch } from '../../utils/oauthAppVersionGate'; import { openUrl } from '../../utils/openUrl'; @@ -247,7 +247,34 @@ const OAuthProviderButton = ({ // it shortcircuits the redirect so the loopback listener never fires. // Only set it when we have no loopback handle (web build, or bind failed). if (IS_DEV && !loopback) params.set('responseType', 'json'); - if (loopback) params.set('redirectUri', loopback.redirectUri); + if (loopback) { + params.set('redirectUri', loopback.redirectUri); + // Bind the inbound `openhuman://auth` deep link to a per-attempt state + // nonce (finding C3). The loopback handle already carries a `state` the + // Rust shell verifies AND the backend echoes back on the callback URL; + // register it so `handleAuthDeepLink` accepts the rewritten callback and + // rejects any unsolicited deep link. + registerAuthDeepLinkState(loopback.state); + } else { + // Fallback `openhuman://auth` deep-link path (Tauri without loopback) and + // the web build (full-page navigation): mint an in-app nonce, pass it to + // the backend so it is echoed back on the callback, then verify on + // return. The web build navigates away and loses module memory, so the + // nonce is also stashed in sessionStorage and re-registered by + // WebCallbackPage; the desktop fallback keeps it in memory. Without this, + // the callback would carry no verifiable state and be rejected (C3). + const nonce = registerAuthDeepLinkState(); + params.set('state', nonce); + if (!isTauri()) { + try { + window.sessionStorage.setItem('openhuman:auth-deep-link-state', nonce); + } catch { + // Private mode / storage disabled — WebCallbackPage will still + // re-register from the echoed `state`, accepting the same-origin + // backend redirect. + } + } + } const loginUrl = params.toString() ? `${loginUrlBase}?${params}` : loginUrlBase; if (loopback) { diff --git a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx index 3ebfc7a22..554a8db0c 100644 --- a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx +++ b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx @@ -26,7 +26,10 @@ vi.mock('../../../utils/tauriCommands', () => ({ isTauri: vi.fn() })); vi.mock('../../../utils/loopbackOauthListener', () => ({ startLoopbackOauthListener: vi.fn() })); -vi.mock('../../../utils/desktopDeepLinkListener', () => ({ handleDeepLinkUrls: vi.fn() })); +vi.mock('../../../utils/desktopDeepLinkListener', () => ({ + handleDeepLinkUrls: vi.fn(), + registerAuthDeepLinkState: vi.fn((state?: string) => state ?? 'mock-state'), +})); vi.mock('../../../store/deepLinkAuthState', () => ({ beginDeepLinkAuthProcessing: vi.fn(), diff --git a/app/src/features/human/Mascot/backend/BackendMascot.tsx b/app/src/features/human/Mascot/backend/BackendMascot.tsx index 995705b1b..e7c1efbec 100644 --- a/app/src/features/human/Mascot/backend/BackendMascot.tsx +++ b/app/src/features/human/Mascot/backend/BackendMascot.tsx @@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef } from 'react'; import { injectViseme, tweenTransform } from './renderCore'; +import { sanitizeSvg } from './sanitizeSvg'; import type { MascotDetail, MascotState } from './types'; export interface BackendMascotProps { @@ -56,7 +57,9 @@ export function BackendMascot({ const initialMarkup = useMemo(() => { if (!state) return ''; const active = viseme && viseme !== 'sil' ? viseme : null; - return injectViseme(state.svg, mascot, active); + // Sanitize before injection — backend-supplied SVG reaches a + // `dangerouslySetInnerHTML` sink in the privileged renderer. + return sanitizeSvg(injectViseme(state.svg, mascot, active)); // We intentionally only re-inject on state change here; viseme effect // below handles in-place swaps. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -82,7 +85,7 @@ export function BackendMascot({ if (!slot) return; const active = viseme && viseme !== 'sil' ? viseme : null; const entry = active ? mascot.visemes.find(v => v.label === active) : null; - const inner = entry?.svg ?? ''; + const inner = sanitizeSvg(entry?.svg ?? ''); const visible = inner.length > 0; slot.innerHTML = inner; slot.setAttribute('opacity', visible ? '1' : '0'); diff --git a/app/src/features/human/Mascot/backend/sanitizeSvg.test.ts b/app/src/features/human/Mascot/backend/sanitizeSvg.test.ts new file mode 100644 index 000000000..0246c397d --- /dev/null +++ b/app/src/features/human/Mascot/backend/sanitizeSvg.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { sanitizeSvg } from './sanitizeSvg'; + +describe('sanitizeSvg', () => { + it('returns empty string for empty input', () => { + expect(sanitizeSvg('')).toBe(''); + }); + + it('preserves benign SVG markup', () => { + const svg = ''; + expect(sanitizeSvg(svg)).toBe(svg); + }); + + it('strips '); + expect(out).not.toContain('script'); + expect(out).toContain(''); + }); + + it('strips elements (the innerHTML execution sink)', () => { + const out = sanitizeSvg( + '' + ); + expect(out.toLowerCase()).not.toContain('foreignobject'); + expect(out).not.toContain('onerror'); + expect(out).toContain(''); + }); + + it('strips inline event-handler attributes', () => { + const out = sanitizeSvg(''); + expect(out).not.toContain('onload'); + expect(out).not.toContain('onclick'); + expect(out).toContain('r="5"'); + }); + + it('strips javascript: and data:text/html URLs in href/xlink:href', () => { + const out = sanitizeSvg( + '' + ); + expect(out.toLowerCase()).not.toContain('javascript:'); + expect(out.toLowerCase()).not.toContain('data:text/html'); + }); + + it('leaves a fragment-only xlink:href intact', () => { + const svg = ''; + expect(sanitizeSvg(svg)).toBe(svg); + }); +}); diff --git a/app/src/features/human/Mascot/backend/sanitizeSvg.ts b/app/src/features/human/Mascot/backend/sanitizeSvg.ts new file mode 100644 index 000000000..568910f36 --- /dev/null +++ b/app/src/features/human/Mascot/backend/sanitizeSvg.ts @@ -0,0 +1,40 @@ +// Minimal SVG sanitizer for backend-supplied mascot markup. +// +// `BackendMascot` injects per-state SVG via `dangerouslySetInnerHTML` and live +// `slot.innerHTML` swaps in the privileged Tauri renderer (which can reach the +// core RPC bearer). `innerHTML` never executes `