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
All checks were successful
Stuffle/nebula-os/pipeline/head This commit looks good
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user