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 `