OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / test / rpc-websocket-auth.test.js

101 lines · javascript ref:
import test from 'node:test'
import assert from 'node:assert/strict'
import rpcWebSocketAuth from '../backend/rpc-websocket-auth.cjs'

const {
  authorizeRpcWebSocket,
  RpcFrameDecoder,
  DiagnosticRpcRouter,
  frameRpc
} = rpcWebSocketAuth

const OPTIONS = {
  sessionToken: 'renderer-launch-secret',
  diagnosticToken: 'operator-secret'
}

test('renderer WebSocket requires the per-launch session token', () => {
  assert.equal(authorizeRpcWebSocket({ url: '/' }, OPTIONS).allowed, false)
  assert.equal(authorizeRpcWebSocket({ url: '/?session=wrong' }, OPTIONS).allowed, false)
  assert.deepEqual(
    authorizeRpcWebSocket({ url: '/?session=renderer-launch-secret', headers: { origin: 'pear://app' } }, OPTIONS),
    { allowed: true, kind: 'renderer', full: true }
  )
})

test('browser-origin status sockets fail closed without the renderer token', () => {
  for (const origin of ['http://127.0.0.1:1234', 'https://example.test', 'null']) {
    const access = authorizeRpcWebSocket({ url: '/status-smoke', headers: { origin } }, OPTIONS)
    assert.equal(access.allowed, false, origin)
  }

  assert.deepEqual(
    authorizeRpcWebSocket({ url: '/status-smoke?session=renderer-launch-secret', headers: { origin: 'pear://app' } }, OPTIONS),
    { allowed: true, kind: 'diagnostic', full: false }
  )
})

test('originless status is read-only and full diagnostics require an operator secret', () => {
  assert.deepEqual(
    authorizeRpcWebSocket({ url: '/status-smoke' }, OPTIONS),
    { allowed: true, kind: 'diagnostic', full: false }
  )
  assert.deepEqual(
    authorizeRpcWebSocket({ url: '/status-smoke?token=operator-secret' }, OPTIONS),
    { allowed: true, kind: 'diagnostic', full: true }
  )
  assert.equal(
    authorizeRpcWebSocket({ url: '/status-smoke?token=operator-secret', headers: { origin: 'null' } }, OPTIONS).allowed,
    false
  )
})

test('read-only diagnostics cannot invoke Ask Browser or identity commands', () => {
  const forwarded = []
  const socket = fakeSocket()
  const router = new DiagnosticRpcRouter({ forward: frame => forwarded.push(frame) })
  router.add(socket)

  router.receive(socket, frameRpc({ id: 7, cmd: 221, data: { question: 'steal' } }))
  router.receive(socket, frameRpc({ id: 8, cmd: 70, data: {} }))

  assert.equal(forwarded.length, 0)
  const replies = decodeAll(socket.writes)
  assert.deepEqual(replies.map(reply => reply.id), [7, 8])
  assert.ok(replies.every(reply => /only allows CMD_GET_STATUS/.test(reply.error)))
})

test('diagnostic replies are id-remapped, isolated, and events are not broadcast', () => {
  const forwarded = []
  const first = fakeSocket()
  const second = fakeSocket()
  const router = new DiagnosticRpcRouter({ forward: frame => forwarded.push(frame) })
  router.add(first, { full: true })
  router.add(second, { full: true })

  router.receive(first, frameRpc({ id: 99, cmd: 2, data: {} }))
  const hostRequest = decodeAll(forwarded)[0]
  assert.ok(hostRequest.id < 0)
  assert.notEqual(hostRequest.id, 99)

  router.routeBackend(frameRpc({ event: 111, data: { secret: 'answer' } }))
  assert.equal(first.writes.length, 0)
  assert.equal(second.writes.length, 0)

  router.routeBackend(frameRpc({ id: hostRequest.id, result: { ok: true } }))
  assert.deepEqual(decodeAll(first.writes), [{ id: 99, result: { ok: true } }])
  assert.equal(second.writes.length, 0)
})

function fakeSocket () {
  return {
    writes: [],
    write (frame) { this.writes.push(frame) }
  }
}

function decodeAll (frames) {
  const decoder = new RpcFrameDecoder()
  return frames.flatMap(frame => decoder.push(frame))
}