Repository navigation
ws: carry auth token in Sec-WebSocket-Protocol instead of the URL #281
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| } | ||
| } | ||
| return null | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
90 changes: 90 additions & 0 deletions
90
packages/transports/tests/neemata/ws/adapter-auth-echo.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.