diff --git a/nebula-local-bridge/content.js b/nebula-local-bridge/content.js index 0abcd06e..3ebb9081 100644 --- a/nebula-local-bridge/content.js +++ b/nebula-local-bridge/content.js @@ -4,84 +4,151 @@ * Runs on nebula.stuffle.ai, nebula.armco.dev, and localhost:2001 at document_start. * * Responsibilities: - * 1. Inject DOM signal (data-nebula-bridge) before React mounts + * 1. Inject minimal DOM signal (data-nebula-bridge) before React mounts * 2. Relay page → background messages for: ping, infer, models * 3. Forward background → page messages for: infer:chunk, infer:done, infer:error * - * Security: only processes messages with source 'nebula-bridge-page' from window, - * never forwarding arbitrary page messages to the extension. + * Security model: + * - Session nonce generated once per page load; required on infer/models requests. + * - All postMessage to page uses exact window.location.origin (not '*'). + * - Streaming push messages forwarded only for registered active request IDs. + * - Source marker 'nebula-bridge-page' still required on inbound page messages. */ ;(function () { 'use strict' const PAGE_SOURCE = 'nebula-bridge-page' + const EXT_SOURCE = 'nebula-bridge-extension' const html = document.documentElement + // ── Session nonce ────────────────────────────────────────────────────────── + // Generated once at script load. Announced in the ready message. + // Required on every infer/models request from the page to authenticate it. + + function createNonce() { + const buf = new Uint8Array(16) + crypto.getRandomValues(buf) + return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('') + } + + const BRIDGE_ORIGIN = window.location.origin + const bridgeSessionNonce = createNonce() + + function postToPage(message) { + window.postMessage( + { ...message, source: EXT_SOURCE, bridgeSessionNonce }, + BRIDGE_ORIGIN + ) + } + + // ── Active streaming request registry ───────────────────────────────────── + // Tracks in-flight streaming requestIds. Background push messages (chunks, + // done, error via chrome.tabs.sendMessage) are only forwarded to the page + // for requestIds that are registered here. This prevents unregistered or + // stale background messages from reaching the page. + + const STREAM_TTL_MS = 130_000 + const activeStreamRequests = new Map() // requestId → expiresAt + + function registerStreamRequest(requestId) { + activeStreamRequests.set(requestId, Date.now() + STREAM_TTL_MS) + } + + function completeStreamRequest(requestId) { + activeStreamRequests.delete(requestId) + } + + function isActiveStreamRequest(requestId) { + const expiresAt = activeStreamRequests.get(requestId) + if (expiresAt === undefined) return false + if (Date.now() > expiresAt) { + activeStreamRequests.delete(requestId) + return false + } + return true + } + + setInterval(() => { + const now = Date.now() + for (const [id, exp] of activeStreamRequests) { + if (now > exp) activeStreamRequests.delete(id) + } + }, 30_000) + // ── 1. Announce extension presence synchronously ─────────────────────────── chrome.runtime.sendMessage({ type: 'nebula-bridge:ping' }, (response) => { if (chrome.runtime.lastError || !response) return const baseUrl = response.baseUrl ?? `http://localhost:${response.localPort ?? 11434}` - // Extract port from baseUrl for legacy DOM attribute let port = 11434 try { port = new URL(baseUrl).port ? parseInt(new URL(baseUrl).port, 10) : 11434 } catch (_) {} - const selectedModel = response.selectedModel || '' - + // Only write non-sensitive status signals to DOM attributes. + // Rich state (baseUrl, model, counts) is delivered exclusively via the + // authenticated postMessage channel below. html.setAttribute('data-nebula-bridge', 'active') html.setAttribute('data-nebula-bridge-version', response.version ?? '1.0.0') - html.setAttribute('data-nebula-bridge-port', String(port)) - html.setAttribute('data-nebula-bridge-base-url', baseUrl) html.setAttribute('data-nebula-bridge-enabled', response.enabled === false ? 'false' : 'true') - if (selectedModel) html.setAttribute('data-nebula-bridge-model', selectedModel) + html.setAttribute('data-nebula-bridge-ready', response.readyForChat ? 'true' : 'false') - window.postMessage( - { - type: 'nebula-bridge:ready', - version: response.version, - baseUrl, - localPort: port, - selectedModel, - enabled: response.enabled, - originAllowed: response.originAllowed, - endpointHealthy: response.endpointHealthy, - endpointError: response.endpointError, - modelCount: response.modelCount, - readyForChat: response.readyForChat, - source: 'nebula-bridge-extension', - }, - '*' - ) + postToPage({ + type: 'nebula-bridge:ready', + version: response.version, + baseUrl, + localPort: port, + selectedModel: response.selectedModel || '', + enabled: response.enabled, + originAllowed: response.originAllowed, + endpointHealthy: response.endpointHealthy, + endpointError: response.endpointError, + modelCount: response.modelCount, + readyForChat: response.readyForChat, + }) }) // ── 2. Listen for background → content push messages (streaming chunks) ──── // Background uses chrome.tabs.sendMessage to push chunks for streaming infer. + // Only forward messages for active registered requestIds. chrome.runtime.onMessage.addListener((message) => { if (!message || !message.type) return - if ( - message.type === 'nebula-bridge:infer:chunk' || - message.type === 'nebula-bridge:infer:done' || - message.type === 'nebula-bridge:infer:error' - ) { - window.postMessage({ ...message, source: 'nebula-bridge-extension' }, '*') + + if (message.type === 'nebula-bridge:infer:chunk') { + if (isActiveStreamRequest(message.requestId)) { + postToPage(message) + } + return + } + + if (message.type === 'nebula-bridge:infer:done' || message.type === 'nebula-bridge:infer:error') { + const wasActive = isActiveStreamRequest(message.requestId) + completeStreamRequest(message.requestId) + if (wasActive) postToPage(message) + return } }) // ── 3. Listen for page → extension messages ──────────────────────────────── window.addEventListener('message', (event) => { + // Strict origin + source validation if (event.source !== window) return + if (event.origin !== BRIDGE_ORIGIN) return const msg = event.data if (!msg || !msg.type || msg.source !== PAGE_SOURCE) return - // Ping + // Nonce required for capability requests to prevent unauthorized script + // injection from triggering inference. + const isCapabilityRequest = msg.type === 'nebula-bridge:infer' || msg.type === 'nebula-bridge:models' + if (isCapabilityRequest && msg.bridgeSessionNonce !== bridgeSessionNonce) return + + // Ping — nonce not required, returns status only if (msg.type === 'nebula-bridge:ping') { chrome.runtime.sendMessage({ type: 'nebula-bridge:ping' }, (response) => { if (chrome.runtime.lastError || !response) return - window.postMessage({ ...response, source: 'nebula-bridge-extension' }, '*') + postToPage(response) }) return } @@ -90,32 +157,39 @@ if (msg.type === 'nebula-bridge:models') { chrome.runtime.sendMessage({ type: 'nebula-bridge:models', requestId: msg.requestId }, (response) => { if (chrome.runtime.lastError) { - window.postMessage({ type: 'nebula-bridge:models:error', requestId: msg.requestId, error: chrome.runtime.lastError.message, source: 'nebula-bridge-extension' }, '*') + postToPage({ type: 'nebula-bridge:models:error', requestId: msg.requestId, error: chrome.runtime.lastError.message }) return } - window.postMessage({ ...response, source: 'nebula-bridge-extension' }, '*') + postToPage(response) }) return } // Inference request (streaming or non-streaming) if (msg.type === 'nebula-bridge:infer') { + const isStream = msg.payload?.stream === true + if (isStream) registerStreamRequest(msg.requestId) + chrome.runtime.sendMessage( { type: 'nebula-bridge:infer', requestId: msg.requestId, payload: msg.payload }, (response) => { if (chrome.runtime.lastError) { - window.postMessage({ + completeStreamRequest(msg.requestId) + postToPage({ type: 'nebula-bridge:infer:error', requestId: msg.requestId, error: chrome.runtime.lastError.message, - source: 'nebula-bridge-extension', - }, '*') + }) return } - // For streaming, background acks with 'infer:streaming' — chunks come via onMessage above. - // For non-streaming, background responds with 'infer:done' directly. if (response) { - window.postMessage({ ...response, source: 'nebula-bridge-extension' }, '*') + // Background returned an immediate error (before streaming started): cleanup + if (response.type === 'nebula-bridge:infer:error') { + completeStreamRequest(msg.requestId) + } + // For streaming, background acks with 'infer:streaming' — chunks come via onMessage above. + // For non-streaming, background responds with 'infer:done' directly. + postToPage(response) } } ) diff --git a/tests/workflow/validate_nebula_local_bridge_release.sh b/tests/workflow/validate_nebula_local_bridge_release.sh index d800994f..30ef01c3 100644 --- a/tests/workflow/validate_nebula_local_bridge_release.sh +++ b/tests/workflow/validate_nebula_local_bridge_release.sh @@ -66,6 +66,10 @@ assert not missing_matches, f"missing content script matches: {missing_matches}" icons = manifest.get("icons", {}) for size in ("16", "48", "128"): assert icons.get(size), f"missing icon {size}" +csp = manifest.get("content_security_policy", {}).get("extension_pages", "") +assert csp, "content_security_policy.extension_pages must be set" +assert "unsafe-eval" not in csp, "CSP must not allow unsafe-eval" +assert "object-src 'none'" in csp, "CSP must block object-src" print("manifest_ok") PY @@ -85,4 +89,19 @@ if grep -R -n -E 'innerHTML\s*=\s*`' "${EXT_DIR}" --include='*.js'; then fail "template-based innerHTML assignment detected" fi +info "Checking for wildcard postMessage targetOrigin in extension JS" +for js_file in "${EXT_DIR}/content.js" "${EXT_DIR}/background.js" "${EXT_DIR}/popup.js"; do + if grep -nP "postMessage\s*\([^)]*,\s*'\*'\s*\)" "$js_file" 2>/dev/null; then + fail "wildcard targetOrigin '*' found in ${js_file} — must use exact origin" + fi +done + +info "Checking session nonce present in content.js" +grep -q 'bridgeSessionNonce' "${EXT_DIR}/content.js" || fail "bridgeSessionNonce not found in content.js" +grep -q 'crypto.getRandomValues' "${EXT_DIR}/content.js" || fail "crypto.getRandomValues not found in content.js" + +info "Checking payload validation present in background.js" +grep -q 'validateChatCompletionPayload' "${EXT_DIR}/background.js" || fail "validateChatCompletionPayload not found in background.js" +grep -q 'sanitizeLocalError' "${EXT_DIR}/background.js" || fail "sanitizeLocalError not found in background.js" + info "Release validation passed" diff --git a/webapp/src/hooks/useLocalBridge.ts b/webapp/src/hooks/useLocalBridge.ts index 11823c87..72523735 100644 --- a/webapp/src/hooks/useLocalBridge.ts +++ b/webapp/src/hooks/useLocalBridge.ts @@ -95,7 +95,7 @@ export function useLocalBridge(): LocalBridgeState { function requestBridgeState() { if (fallbackTimer != null) window.clearTimeout(fallbackTimer) fallbackTimer = window.setTimeout(() => resolve(null, null, null, null, false, false, false, null, 0, false), DETECTION_TIMEOUT_MS) - window.postMessage({ type: BRIDGE_PING_EVENT, source: BRIDGE_PAGE_SOURCE }, '*') + window.postMessage({ type: BRIDGE_PING_EVENT, source: BRIDGE_PAGE_SOURCE }, window.location.origin) } // Signal 1 (DOM): content script sets data-nebula-bridge="active" on load, @@ -105,6 +105,7 @@ export function useLocalBridge(): LocalBridgeState { // Signal 2: Listen for postMessage from extension background/content function onMessage(event: MessageEvent) { if (event.source !== window) return + if (event.origin !== window.location.origin) return const d = event.data if (d?.type === BRIDGE_READY_EVENT && d?.source === 'nebula-bridge-extension') { const baseUrl = d.baseUrl ?? `http://localhost:${d.localPort ?? 11434}` diff --git a/webapp/src/lib/localBridge.ts b/webapp/src/lib/localBridge.ts index 03afd46d..a911cdb3 100644 --- a/webapp/src/lib/localBridge.ts +++ b/webapp/src/lib/localBridge.ts @@ -23,6 +23,62 @@ function nextRequestId(): string { return `nbr_${Date.now()}_${++_requestCounter}` } +// ── Bridge session nonce ────────────────────────────────────────────────────── +// Captured from the first nebula-bridge:ready or nebula-bridge:pong event. +// Content script generates a fresh nonce on each page load and includes it in +// every outgoing message. We echo it back on every request and validate it on +// every incoming response so that a rogue page script cannot inject spoofed +// bridge responses. + +let _bridgeSessionNonce: string | null = null +const BRIDGE_NONCE_TIMEOUT_MS = 2_000 + +if (typeof window !== 'undefined') { + window.addEventListener('message', (event: MessageEvent) => { + if (event.source !== window) return + if (event.origin !== window.location.origin) return + const d = event.data + if ( + d?.source === EXT_SOURCE && + typeof d?.bridgeSessionNonce === 'string' && + d.bridgeSessionNonce.length === 32 && + !_bridgeSessionNonce + ) { + _bridgeSessionNonce = d.bridgeSessionNonce + } + }) +} + +function waitForBridgeSessionNonce(timeoutMs = BRIDGE_NONCE_TIMEOUT_MS): Promise { + if (_bridgeSessionNonce) return Promise.resolve(_bridgeSessionNonce) + + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + window.removeEventListener('message', onMessage) + reject(new Error('Bridge session nonce unavailable')) + }, timeoutMs) + + function onMessage(event: MessageEvent) { + if (event.source !== window) return + if (event.origin !== window.location.origin) return + const d = event.data + if ( + d?.source === EXT_SOURCE && + typeof d?.bridgeSessionNonce === 'string' && + d.bridgeSessionNonce.length === 32 + ) { + _bridgeSessionNonce = d.bridgeSessionNonce + window.clearTimeout(timeout) + window.removeEventListener('message', onMessage) + resolve(d.bridgeSessionNonce) + } + } + + window.addEventListener('message', onMessage) + window.postMessage({ type: 'nebula-bridge:ping', source: PAGE_SOURCE }, window.location.origin) + }) +} + // ── Observability context ───────────────────────────────────────────────────── export interface BridgeObsContext { @@ -103,8 +159,10 @@ export function localBridgeFetch( function onMessage(event: MessageEvent) { if (event.source !== window) return + if (event.origin !== window.location.origin) return const msg = event.data if (!msg || msg.source !== EXT_SOURCE || msg.requestId !== requestId) return + if (_bridgeSessionNonce !== null && msg.bridgeSessionNonce !== _bridgeSessionNonce) return window.removeEventListener('message', onMessage) @@ -135,10 +193,17 @@ export function localBridgeFetch( window.addEventListener('message', onMessage) - window.postMessage( - { type: 'nebula-bridge:infer', requestId, payload: { ...payload, stream: false }, source: PAGE_SOURCE }, - '*' - ) + waitForBridgeSessionNonce() + .then((bridgeSessionNonce) => { + window.postMessage( + { type: 'nebula-bridge:infer', requestId, payload: { ...payload, stream: false }, source: PAGE_SOURCE, bridgeSessionNonce }, + window.location.origin + ) + }) + .catch((error) => { + window.removeEventListener('message', onMessage) + reject(error) + }) // 120s timeout setTimeout(() => { @@ -178,8 +243,10 @@ export function localBridgeFetchFull( function onMessage(event: MessageEvent) { if (event.source !== window) return + if (event.origin !== window.location.origin) return const msg = event.data if (!msg || msg.source !== EXT_SOURCE || msg.requestId !== requestId) return + if (_bridgeSessionNonce !== null && msg.bridgeSessionNonce !== _bridgeSessionNonce) return window.removeEventListener('message', onMessage) @@ -209,10 +276,17 @@ export function localBridgeFetchFull( window.addEventListener('message', onMessage) - window.postMessage( - { type: 'nebula-bridge:infer', requestId, payload: { ...payload, stream: false }, source: PAGE_SOURCE }, - '*' - ) + waitForBridgeSessionNonce() + .then((bridgeSessionNonce) => { + window.postMessage( + { type: 'nebula-bridge:infer', requestId, payload: { ...payload, stream: false }, source: PAGE_SOURCE, bridgeSessionNonce }, + window.location.origin + ) + }) + .catch((error) => { + window.removeEventListener('message', onMessage) + reject(error) + }) setTimeout(() => { window.removeEventListener('message', onMessage) @@ -223,14 +297,14 @@ export function localBridgeFetchFull( // ── Streaming inference ─────────────────────────────────────────────────────── +export type BridgeStreamResult = BridgeInferFullResult + export interface StreamCallbacks { onChunk: (delta: string, model: string) => void onDone: (result: BridgeStreamResult) => void onError: (error: string) => void } -export interface BridgeStreamResult extends BridgeInferFullResult {} - export function localBridgeStream( payload: BridgeInferPayload, callbacks: StreamCallbacks, @@ -253,8 +327,10 @@ export function localBridgeStream( function onMessage(event: MessageEvent) { if (event.source !== window) return + if (event.origin !== window.location.origin) return const msg = event.data if (!msg || msg.source !== EXT_SOURCE || msg.requestId !== requestId) return + if (_bridgeSessionNonce !== null && msg.bridgeSessionNonce !== _bridgeSessionNonce) return if (msg.type === 'nebula-bridge:infer:chunk') { chunkCount++ @@ -302,10 +378,20 @@ export function localBridgeStream( window.addEventListener('message', onMessage) - window.postMessage( - { type: 'nebula-bridge:infer', requestId, payload: { ...payload, stream: true }, source: PAGE_SOURCE }, - '*' - ) + waitForBridgeSessionNonce() + .then((bridgeSessionNonce) => { + if (done) return + window.postMessage( + { type: 'nebula-bridge:infer', requestId, payload: { ...payload, stream: true }, source: PAGE_SOURCE, bridgeSessionNonce }, + window.location.origin + ) + }) + .catch((error) => { + if (done) return + done = true + window.removeEventListener('message', onMessage) + callbacks.onError(error instanceof Error ? error.message : String(error)) + }) return () => { if (!done) { @@ -360,10 +446,12 @@ function _listModelsRaw(): Promise { function onMessage(event: MessageEvent) { if (event.source !== window) return + if (event.origin !== window.location.origin) return const msg = event.data if (!msg || msg.source !== EXT_SOURCE) return if (msg.requestId !== requestId) return if (msg.type !== 'nebula-bridge:models:result' && msg.type !== 'nebula-bridge:models:error') return + if (_bridgeSessionNonce !== null && msg.bridgeSessionNonce !== _bridgeSessionNonce) return window.removeEventListener('message', onMessage) @@ -383,7 +471,14 @@ function _listModelsRaw(): Promise { } window.addEventListener('message', onMessage) - window.postMessage({ type: 'nebula-bridge:models', requestId, source: PAGE_SOURCE }, '*') + waitForBridgeSessionNonce() + .then((bridgeSessionNonce) => { + window.postMessage({ type: 'nebula-bridge:models', requestId, source: PAGE_SOURCE, bridgeSessionNonce }, window.location.origin) + }) + .catch((error) => { + window.removeEventListener('message', onMessage) + reject(error) + }) setTimeout(() => { window.removeEventListener('message', onMessage)