Security hardening: nonce-authenticated bridge channel, exact-origin postMessage, active streaming registry, payload validation, CSP
All checks were successful
Stuffle/nebula-os/pipeline/head This commit looks good

This commit is contained in:
2026-05-08 23:21:40 +05:30
parent be512d9937
commit 367a0f7d2c
4 changed files with 247 additions and 58 deletions

View File

@@ -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)
}
}
)

View File

@@ -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"

View File

@@ -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}`

View File

@@ -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<string> {
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<BridgeModelInfo[]> {
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<BridgeModelInfo[]> {
}
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)