Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions packages/client/src/transports/ws/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { ProtocolVersion } from '@nmtjs/protocol'
import type { BaseClientCodec } from '@nmtjs/protocol/client'
import { once } from '@nmtjs/common'
import { ConnectionType, ErrorCode } from '@nmtjs/protocol'
import {
ConnectionType,
encodeWsAuthSubprotocol,
ErrorCode,
} from '@nmtjs/protocol'
import { ProtocolError } from '@nmtjs/protocol/client'

import type {
Expand Down Expand Up @@ -57,13 +61,15 @@ export class WsTransportClient implements BidirectionalTransport {
url.searchParams.set('content-type', this.codec.contentType)
url.searchParams.set('accept', this.codec.contentType)

const protocols: string[] = []

if (params.auth) {
url.searchParams.set('auth', params.auth)
protocols.push(encodeWsAuthSubprotocol(params.auth))
}

const ws = this.options.WebSocket
? new this.options.WebSocket(url)
: new WebSocket(url.toString())
? new this.options.WebSocket(url, protocols)
: new WebSocket(url.toString(), protocols)

ws.binaryType = 'arraybuffer'

Expand Down
13 changes: 10 additions & 3 deletions packages/client/tests/ws/client.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ErrorCode, ProtocolVersion } from '@nmtjs/protocol'
import {
encodeWsAuthSubprotocol,
ErrorCode,
ProtocolVersion,
} from '@nmtjs/protocol'
import { BaseClientCodec, ProtocolError } from '@nmtjs/protocol/client'
import { afterEach, describe, expect, it, vi } from 'vitest'

Expand Down Expand Up @@ -30,6 +34,7 @@ class FakeWebSocket {
static instances: FakeWebSocket[] = []

readonly url: URL
readonly protocols: string[]
binaryType = 'blob'
readyState = 0 // CONNECTING
readonly send = vi.fn()
Expand All @@ -41,8 +46,9 @@ class FakeWebSocket {

#listeners = new Map<string, Set<Listener>>()

constructor(url: string | URL) {
constructor(url: string | URL, protocols: string | string[] = []) {
this.url = url instanceof URL ? url : new URL(url)
this.protocols = Array.isArray(protocols) ? protocols : [protocols]
FakeWebSocket.instances.push(this)
}

Expand Down Expand Up @@ -91,7 +97,8 @@ describe('WsTransportClient', () => {
const socket = FakeWebSocket.instances.at(-1)
expect(socket).toBeDefined()
expect(socket?.url.toString()).toContain('/demo')
expect(socket?.url.searchParams.get('auth')).toBe('Bearer t')
expect(socket?.url.searchParams.get('auth')).toBeNull()
expect(socket?.protocols).toEqual([encodeWsAuthSubprotocol('Bearer t')])

socket!.emit('open')
await connectPromise
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './constants.ts'
export * from './enums.ts'
export * from './types.ts'
export * from './utils.ts'
export * from './ws.ts'
51 changes: 51 additions & 0 deletions packages/protocol/src/common/ws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* WebSocket auth subprotocol encoding, shared by the WS client and server
* transports. Browsers' WebSocket constructor cannot set arbitrary headers,
* so the auth token rides `Sec-WebSocket-Protocol` instead of the URL, where
* it would leak into proxy/access logs, browser history and Referer.
*/

/** Distinguishes the auth entry from real application subprotocols. */
export const WS_AUTH_SUBPROTOCOL_PREFIX = 'nmt.auth.'

export type WsAuthSubprotocol = {
auth: string
/** The exact offered entry the server must echo in the handshake. */
subprotocol: string
}

export const encodeWsAuthSubprotocol = (auth: string): string => {
const bytes = new TextEncoder().encode(auth)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
// Base64url without padding keeps arbitrary auth values inside RFC 6455's
// subprotocol token grammar.
return (
WS_AUTH_SUBPROTOCOL_PREFIX +
btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
)
}

/** Finds the auth entry in a comma-separated Sec-WebSocket-Protocol offer. */
export const matchWsAuthSubprotocol = (
header: string | null,
): WsAuthSubprotocol | null => {
if (!header) return null
for (const entry of header.split(',')) {
const subprotocol = entry.trim()
if (!subprotocol.startsWith(WS_AUTH_SUBPROTOCOL_PREFIX)) continue
const payload = subprotocol
.slice(WS_AUTH_SUBPROTOCOL_PREFIX.length)
.replaceAll('-', '+')
.replaceAll('_', '/')
try {
const bytes = Uint8Array.from(atob(payload), (char) => char.charCodeAt(0))
// Lossy decoding could collapse distinct credentials into one identity.
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
return { auth: decoder.decode(bytes), subprotocol }
} catch {
// A malformed auth entry is foreign to this protocol.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return null
}
66 changes: 66 additions & 0 deletions packages/protocol/tests/common/ws.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'

import {
encodeWsAuthSubprotocol,
matchWsAuthSubprotocol,
WS_AUTH_SUBPROTOCOL_PREFIX,
} from '../../src/common/ws.ts'

// RFC 6455 subprotocol names are RFC 2616 tokens: no separators or spaces.
const RFC6455_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/

describe('encodeWsAuthSubprotocol', () => {
it('produces a valid RFC 6455 token for arbitrary values', () => {
for (const auth of [
'Bearer abc.def.ghi',
'a=b;c/d+e',
'токен-😀',
'x'.repeat(500),
]) {
const subprotocol = encodeWsAuthSubprotocol(auth)
expect(subprotocol.startsWith(WS_AUTH_SUBPROTOCOL_PREFIX)).toBe(true)
expect(subprotocol).toMatch(RFC6455_TOKEN)
}
})

it('round-trips through matchWsAuthSubprotocol', () => {
const subprotocol = encodeWsAuthSubprotocol('Bearer t')
expect(matchWsAuthSubprotocol(subprotocol)).toEqual({
auth: 'Bearer t',
subprotocol,
})
})
})

describe('matchWsAuthSubprotocol', () => {
it('ignores missing and foreign subprotocols', () => {
expect(matchWsAuthSubprotocol(null)).toBeNull()
expect(matchWsAuthSubprotocol('')).toBeNull()
expect(matchWsAuthSubprotocol('chat, graphql-ws')).toBeNull()
})

it('finds the auth entry among other offers', () => {
const subprotocol = encodeWsAuthSubprotocol('secret')
expect(matchWsAuthSubprotocol(`chat, ${subprotocol}, superchat`)).toEqual({
auth: 'secret',
subprotocol,
})
})

it('treats malformed auth entries as foreign', () => {
expect(
matchWsAuthSubprotocol(`${WS_AUTH_SUBPROTOCOL_PREFIX}!!!not-base64!!!`),
).toBeNull()
})

it('preserves a leading BOM in the credential', () => {
const auth = 'secret'
const subprotocol = encodeWsAuthSubprotocol(auth)
expect(matchWsAuthSubprotocol(subprotocol)).toEqual({ auth, subprotocol })
})

it('rejects base64url that is not valid UTF-8', () => {
// _w is the lone byte 0xff; replacement decoding must not turn it into U+FFFD.
expect(matchWsAuthSubprotocol(`${WS_AUTH_SUBPROTOCOL_PREFIX}_w`)).toBeNull()
})
})
25 changes: 21 additions & 4 deletions packages/transports/src/neemata/ws/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
import type { ProtocolCodecRegistry, SendResult } from '@nmtjs/protocol/server'
import type { Hooks, Peer } from 'crossws'
import { ProxyableTransportType } from '@nmtjs/gateway'
import { ProtocolVersion } from '@nmtjs/protocol'
import { matchWsAuthSubprotocol, ProtocolVersion } from '@nmtjs/protocol'
import { negotiateCodecs } from '@nmtjs/protocol/server'
import { defineHooks } from 'crossws'

Expand Down Expand Up @@ -272,14 +272,24 @@ export class NeemataWebSocketHandler {
}

private async upgrade(req: Parameters<NonNullable<Hooks['upgrade']>>[0]) {
const url = new URL(req.url)
const authSubprotocol = matchWsAuthSubprotocol(
req.headers.get('sec-websocket-protocol'),
)
const headers = new Headers(req.headers)
// HTTP and WebSocket handlers now expose auth through the same standard
// Request convention, without widening connectionData beyond Request.
if (authSubprotocol) {
headers.set('authorization', authSubprotocol.auth)
}

const request: NeemataWebSocketRequest = new Request(req.url, {
headers: req.headers,
headers,
method: req.method,
// Preserve disconnect propagation while materializing crossws' request
// proxy as a standards-compliant Request for application consumers.
signal: req.signal,
})
const url = new URL(request.url)
const accept = url.searchParams.get('accept') ?? req.headers.get('accept')
const contentType =
url.searchParams.get('content-type') ?? req.headers.get('content-type')
Expand All @@ -305,7 +315,14 @@ export class NeemataWebSocketHandler {
decoder,
})
this.connections.admit(connection.id)
return { context: { connectionId: connection.id } }
return {
context: { connectionId: connection.id },
// Browsers reject a handshake unless the server echoes one exact
// offered subprotocol.
headers: authSubprotocol
? { 'sec-websocket-protocol': authSubprotocol.subprotocol }
: undefined,
}
} catch (error) {
console.error('Failed to upgrade WebSocket connection', error)
return InternalServerErrorHttpResponse()
Expand Down
5 changes: 5 additions & 0 deletions packages/transports/src/neemata/ws/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import type { ProtocolCodecRegistry } from '@nmtjs/protocol/server'

import type { WsSessionHeartbeatOptions } from './session.ts'

/**
* Standard upgrade Request exposed as connection data. Auth from the Neemata
* subprotocol is available through its `Authorization` header, matching the
* HTTP transport convention.
*/
export type NeemataWebSocketRequest = Request

export type WsTransportPeerContext = { connectionId: string }
Expand Down
90 changes: 90 additions & 0 deletions packages/transports/tests/neemata/ws/adapter-auth-echo.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { ServerHost } from '@nmtjs/transports/http-server'
import { encodeWsAuthSubprotocol } from '@nmtjs/protocol'
import { JsonCodec } from '@nmtjs/protocol/json/server'
import { ProtocolCodecRegistry } from '@nmtjs/protocol/server'
import createBunAdapter from 'crossws/adapters/bun'
import createDenoAdapter from 'crossws/adapters/deno'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { NeemataWebSocketHandler } from '../../../src/neemata/ws/server.ts'

const createHandler = () => {
const onConnect = vi.fn(async (_options: any) => ({ id: 'c1' }))
const handler = new NeemataWebSocketHandler(
{ onConnect, onDisconnect: vi.fn(async () => {}) } as any,
{ isSendSuccess: () => true } as unknown as ServerHost,
new ProtocolCodecRegistry([new JsonCodec()]),
{ path: '/', heartbeat: false },
)
return { handler, onConnect }
}

const authRequest = (subprotocol: string) =>
new Request(
'http://localhost/ws?accept=application/json&content-type=application/json',
{ headers: { 'sec-websocket-protocol': subprotocol } },
)

const originalBun = Object.getOwnPropertyDescriptor(globalThis, 'Bun')
const originalDeno = Object.getOwnPropertyDescriptor(globalThis, 'Deno')

afterEach(() => {
if (!originalBun) Reflect.deleteProperty(globalThis, 'Bun')
if (originalDeno) Object.defineProperty(globalThis, 'Deno', originalDeno)
else Reflect.deleteProperty(globalThis, 'Deno')
})

describe('auth echo through Request-based runtime adapters', () => {
it('passes the exact selection to Bun server.upgrade', async () => {
if (!originalBun) {
Object.defineProperty(globalThis, 'Bun', {
configurable: true,
value: {},
})
}
const { handler, onConnect } = createHandler()
const adapter = createBunAdapter({ hooks: handler.hooks })
const upgrade = vi.fn((_request: Request, _options: any) => true)
const subprotocol = encodeWsAuthSubprotocol('Bearer bun')

await adapter.handleUpgrade(authRequest(subprotocol), { upgrade } as any)

const options = upgrade.mock.calls[0]![1]
expect(new Headers(options.headers).get('sec-websocket-protocol')).toBe(
subprotocol,
)
expect(
(onConnect.mock.calls[0]![0].data as Request).headers.get(
'authorization',
),
).toBe('Bearer bun')
await handler.dispose()
})

it('passes the exact selection as Deno protocol and response header', async () => {
const addEventListener = vi.fn()
const upgradeWebSocket = vi.fn((_request: Request, _options: any) => ({
response: new Response(),
socket: { addEventListener },
}))
Object.defineProperty(globalThis, 'Deno', {
configurable: true,
value: { upgradeWebSocket },
})
const { handler, onConnect } = createHandler()
const adapter = createDenoAdapter({ hooks: handler.hooks })
const subprotocol = encodeWsAuthSubprotocol('Bearer deno')

await adapter.handleUpgrade(authRequest(subprotocol), {} as any)

const options = upgradeWebSocket.mock.calls[0]![1]
expect(options.protocol).toBe(subprotocol)
expect(options.headers.get('sec-websocket-protocol')).toBe(subprotocol)
expect(
(onConnect.mock.calls[0]![0].data as Request).headers.get(
'authorization',
),
).toBe('Bearer deno')
await handler.dispose()
})
})
Loading
Loading