diff --git a/package.json b/package.json index a0674ce..8a835d2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "i18n:validate": "node scripts/i18n-validate.cjs", "test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts", "test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts", + "test:notifications-security": "tsx --test scripts/notifications-security.test.ts", "test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs", "test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts", "test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts", diff --git a/scripts/notifications-security.test.ts b/scripts/notifications-security.test.ts new file mode 100644 index 0000000..a29dd38 --- /dev/null +++ b/scripts/notifications-security.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { handleNotificationsHub, handleNotificationsNegotiate } from '../src/handlers/notifications'; +import type { Env } from '../src/types'; +import { createJWT } from '../src/utils/jwt'; + +const secret = 'notification-security-test-secret-32-bytes'; +const userId = 'd75020e1-2de4-46e8-b8f1-475d127b51f2'; +const securityStamp = 'security-stamp'; + +function createTestEnv() { + const connectionTokens = new Map(); + const forwardedHubUrls: string[] = []; + const durableObjectNames: string[] = []; + const userRow = { + id: userId, + email: 'user@example.test', + name: 'Test User', + master_password_hint: null, + master_password_hash: 'hash', + key: 'key', + private_key: null, + public_key: null, + kdf_type: 0, + kdf_iterations: 600000, + kdf_memory: null, + kdf_parallelism: null, + security_stamp: securityStamp, + role: 'user', + status: 'active', + verify_devices: 0, + totp_secret: null, + totp_recovery_code: null, + yubikey_key1: null, + yubikey_key2: null, + yubikey_key3: null, + yubikey_key4: null, + yubikey_key5: null, + yubikey_nfc: 0, + api_key: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + const db = { + prepare() { + return { + bind() { + return this; + }, + async first() { + return userRow; + }, + }; + }, + } as unknown as D1Database; + + const stub = { + async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const request = new Request(input, init); + const url = new URL(request.url); + if (url.pathname === '/internal/ws-token') { + const body = await request.json() as { + token: string; + userId: string; + deviceIdentifier: string | null; + expiresAt: number; + }; + connectionTokens.set(body.token, body); + return new Response(null, { status: 204 }); + } + if (url.pathname === '/internal/ws-token/consume') { + const { token } = await request.json() as { token: string }; + const connection = connectionTokens.get(token); + connectionTokens.delete(token); + if (!connection || connection.expiresAt <= Date.now()) return new Response(null, { status: 401 }); + return Response.json(connection); + } + forwardedHubUrls.push(request.url); + return new Response(null, { status: 204 }); + }, + }; + + const env = { + DB: db, + JWT_SECRET: secret, + NOTIFICATIONS_HUB: { + idFromName(name: string) { + durableObjectNames.push(name); + return name; + }, + get() { + return stub; + }, + }, + } as unknown as Env; + + return { env, connectionTokens, durableObjectNames, forwardedHubUrls }; +} + +async function validAccessToken(): Promise { + return createJWT({ + sub: userId, + email: 'user@example.test', + name: 'Test User', + sstamp: securityStamp, + }, secret); +} + +test('query access_token cannot authenticate a websocket', async () => { + const { env, forwardedHubUrls } = createTestEnv(); + const token = await validAccessToken(); + const response = await handleNotificationsHub(new Request( + `https://vault.example.test/notifications/hub?access_token=${encodeURIComponent(token)}`, + { headers: { Upgrade: 'websocket' } } + ), env); + + assert.equal(response.status, 401); + assert.deepEqual(forwardedHubUrls, []); +}); + +test('Authorization bearer token still authenticates notifications', async () => { + const { env, forwardedHubUrls } = createTestEnv(); + const token = await validAccessToken(); + const response = await handleNotificationsHub(new Request( + 'https://vault.example.test/notifications/hub', + { headers: { Authorization: `Bearer ${token}`, Upgrade: 'websocket' } } + ), env); + + assert.equal(response.status, 204); + assert.equal(new URL(forwardedHubUrls[0]).searchParams.get('nw_uid'), userId); +}); + +test('negotiate issues a short-lived one-time websocket connection token', async () => { + const { env, connectionTokens, forwardedHubUrls } = createTestEnv(); + const accessToken = await validAccessToken(); + const negotiate = await handleNotificationsNegotiate(new Request( + 'https://vault.example.test/notifications/hub/negotiate', + { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` } } + ), env); + const body = await negotiate.json() as { connectionToken: string }; + const stored = connectionTokens.get(body.connectionToken); + + assert.equal(negotiate.status, 200); + assert.ok(stored); + assert.ok(stored.expiresAt > Date.now()); + assert.ok(stored.expiresAt <= Date.now() + 60_000); + + const request = () => new Request( + `https://vault.example.test/notifications/hub?id=${encodeURIComponent(body.connectionToken)}`, + { headers: { Upgrade: 'websocket' } } + ); + assert.equal((await handleNotificationsHub(request(), env)).status, 204); + assert.equal((await handleNotificationsHub(request(), env)).status, 401); + assert.equal(forwardedHubUrls.length, 1); +}); + +test('a non-upgrade request does not consume a websocket connection token', async () => { + const { env, connectionTokens } = createTestEnv(); + const accessToken = await validAccessToken(); + const negotiate = await handleNotificationsNegotiate(new Request( + 'https://vault.example.test/notifications/hub/negotiate', + { method: 'POST', headers: { Authorization: `Bearer ${accessToken}` } } + ), env); + const { connectionToken } = await negotiate.json() as { connectionToken: string }; + const url = `https://vault.example.test/notifications/hub?id=${encodeURIComponent(connectionToken)}`; + + assert.equal((await handleNotificationsHub(new Request(url), env)).status, 426); + assert.ok(connectionTokens.has(connectionToken)); + assert.equal((await handleNotificationsHub(new Request(url, { headers: { Upgrade: 'websocket' } }), env)).status, 204); +}); + +test('a forged ticket cannot select or activate a Durable Object', async () => { + const { env, durableObjectNames } = createTestEnv(); + const response = await handleNotificationsHub(new Request( + 'https://vault.example.test/notifications/hub?id=attacker-controlled.invalid-signature', + { headers: { Upgrade: 'websocket' } } + ), env); + + assert.equal(response.status, 401); + assert.deepEqual(durableObjectNames, []); +}); diff --git a/src/durable/notifications-hub.ts b/src/durable/notifications-hub.ts index f367bb2..b8b4287 100644 --- a/src/durable/notifications-hub.ts +++ b/src/durable/notifications-hub.ts @@ -19,6 +19,8 @@ const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14; const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15; const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16; const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102; +const WEBSOCKET_CONNECTION_TOKEN_PREFIX = 'ws-token:'; +const WEBSOCKET_CONNECTION_TOKEN_TTL_MS = 60 * 1000; type HubProtocol = 'json' | 'messagepack'; type HubKind = 'user' | 'anonymous-auth-request'; @@ -32,6 +34,12 @@ interface WsAttachment { deviceIdentifier: string | null; } +interface WebSocketConnectionToken { + userId: string; + deviceIdentifier: string | null; + expiresAt: number; +} + function concatBytes(chunks: Uint8Array[]): Uint8Array { const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const out = new Uint8Array(total); @@ -207,6 +215,52 @@ export class NotificationsHub extends DurableObject { async fetch(request: Request): Promise { const url = new URL(request.url); + if (url.pathname === '/internal/ws-token' && request.method === 'POST') { + const body = (await request.json().catch(() => null)) as { + token?: string; + userId?: string; + deviceIdentifier?: string | null; + expiresAt?: number; + } | null; + const token = String(body?.token || '').trim(); + const userId = String(body?.userId || '').trim(); + const expiresAt = Number(body?.expiresAt || 0); + if (!token || !userId || expiresAt <= Date.now() || expiresAt > Date.now() + WEBSOCKET_CONNECTION_TOKEN_TTL_MS) { + return new Response('Invalid websocket connection token', { status: 400 }); + } + await this.ctx.storage.put(`${WEBSOCKET_CONNECTION_TOKEN_PREFIX}${token}`, { + userId, + deviceIdentifier: String(body?.deviceIdentifier || '').trim() || null, + expiresAt, + } satisfies WebSocketConnectionToken); + const currentAlarm = await this.ctx.storage.getAlarm(); + if (currentAlarm === null || expiresAt < currentAlarm) { + await this.ctx.storage.setAlarm(expiresAt); + } + return new Response(null, { status: 204 }); + } + + if (url.pathname === '/internal/ws-token/consume' && request.method === 'POST') { + const body = (await request.json().catch(() => null)) as { token?: string } | null; + const token = String(body?.token || '').trim(); + if (!token) return new Response('Invalid websocket connection token', { status: 400 }); + + // Delete inside a transaction so a connection ticket cannot win two concurrent upgrades. + const connection = await this.ctx.storage.transaction(async (txn) => { + const key = `${WEBSOCKET_CONNECTION_TOKEN_PREFIX}${token}`; + const stored = await txn.get(key); + if (stored) await txn.delete(key); + return stored || null; + }); + if (!connection || connection.expiresAt <= Date.now()) { + return new Response('Unauthorized', { status: 401 }); + } + return new Response(JSON.stringify(connection), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.pathname === '/internal/notify' && request.method === 'POST') { const body = (await request.json().catch(() => null)) as { revisionDate?: string; @@ -301,6 +355,27 @@ export class NotificationsHub extends DurableObject { }); } + async alarm(): Promise { + const now = Date.now(); + const tokens = await this.ctx.storage.list({ + prefix: WEBSOCKET_CONNECTION_TOKEN_PREFIX, + }); + const expiredKeys: string[] = []; + let nextExpiration: number | null = null; + + for (const [key, token] of tokens) { + if (token.expiresAt <= now) { + expiredKeys.push(key); + } else if (nextExpiration === null || token.expiresAt < nextExpiration) { + nextExpiration = token.expiresAt; + } + } + + // Negotiated tickets that never reach an upgrade must not remain in DO storage indefinitely. + if (expiredKeys.length > 0) await this.ctx.storage.delete(expiredKeys); + if (nextExpiration !== null) await this.ctx.storage.setAlarm(nextExpiration); + } + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise { const attachment = ws.deserializeAttachment() as WsAttachment | null; if (!attachment) return; diff --git a/src/handlers/notifications.ts b/src/handlers/notifications.ts index fecb822..fc2667e 100644 --- a/src/handlers/notifications.ts +++ b/src/handlers/notifications.ts @@ -4,18 +4,20 @@ import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import type { Env, JWTPayload } from '../types'; import { errorResponse, jsonResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; +import { + createWebSocketConnectionToken, + verifyWebSocketConnectionToken, +} from '../utils/websocket-connection-token'; + +const WEBSOCKET_CONNECTION_TOKEN_TTL_MS = 60 * 1000; function extractAccessToken(request: Request): string | null { - const url = new URL(request.url); - const queryToken = String(url.searchParams.get('access_token') || '').trim(); - if (queryToken) return queryToken; - const authHeader = String(request.headers.get('Authorization') || '').trim(); const match = authHeader.match(/^Bearer\s+(.+)$/i); return match?.[1]?.trim() || null; } -async function authenticateNotificationsRequest(request: Request, env: Env): Promise { +async function authenticateAccessToken(request: Request, env: Env): Promise { const accessToken = extractAccessToken(request); if (!accessToken) return null; @@ -23,30 +25,90 @@ async function authenticateNotificationsRequest(request: Request, env: Env): Pro return auth.verifyAccessToken(`Bearer ${accessToken}`); } +async function issueWebSocketConnectionToken(payload: JWTPayload, env: Env): Promise { + const expiresAt = Date.now() + WEBSOCKET_CONNECTION_TOKEN_TTL_MS; + const token = await createWebSocketConnectionToken(payload.sub, expiresAt, env.JWT_SECRET); + const id = env.NOTIFICATIONS_HUB.idFromName(payload.sub); + const stub = env.NOTIFICATIONS_HUB.get(id); + const response = await stub.fetch('https://notifications/internal/ws-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token, + userId: payload.sub, + deviceIdentifier: payload.did || null, + expiresAt, + }), + }); + if (!response.ok) throw new Error('Failed to issue websocket connection token'); + return token; +} + +async function consumeWebSocketConnectionToken(request: Request, env: Env): Promise { + const token = String(new URL(request.url).searchParams.get('id') || '').trim(); + const claims = await verifyWebSocketConnectionToken(token, env.JWT_SECRET); + if (!claims) return null; + + // Verify the signed routing claim before selecting a Durable Object. Otherwise an + // attacker could activate arbitrary object names with forged token prefixes. + const id = env.NOTIFICATIONS_HUB.idFromName(claims.userId); + const stub = env.NOTIFICATIONS_HUB.get(id); + const response = await stub.fetch('https://notifications/internal/ws-token/consume', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + if (!response.ok) return null; + + const connection = (await response.json().catch(() => null)) as { + userId?: string; + deviceIdentifier?: string | null; + } | null; + if (connection?.userId !== claims.userId) return null; + + return { + sub: claims.userId, + did: String(connection.deviceIdentifier || '').trim() || undefined, + } as JWTPayload; +} + +async function authenticateNotificationsHub(request: Request, env: Env): Promise { + // Never accept an access JWT from the URL: URLs are routinely retained by logs, + // browser history, proxies, monitoring, and error tracking systems. + if (request.headers.has('Authorization')) { + return authenticateAccessToken(request, env); + } + return consumeWebSocketConnectionToken(request, env); +} + export async function handleNotificationsNegotiate(request: Request, env: Env): Promise { - const payload = await authenticateNotificationsRequest(request, env); + const payload = await authenticateAccessToken(request, env); if (!payload?.sub) return errorResponse('Unauthorized', 401); - const connectionId = generateUUID(); - return jsonResponse({ - connectionId, - connectionToken: connectionId, - negotiateVersion: 1, - availableTransports: [ - { - transport: 'WebSockets', - transferFormats: ['Text', 'Binary'], - }, - ], - }); + const connectionToken = await issueWebSocketConnectionToken(payload, env); + return jsonResponse( + { + connectionId: generateUUID(), + connectionToken, + negotiateVersion: 1, + availableTransports: [ + { + transport: 'WebSockets', + transferFormats: ['Text', 'Binary'], + }, + ], + }, + 200, + { 'Cache-Control': 'no-store' } + ); } export async function handleNotificationsHub(request: Request, env: Env): Promise { - const payload = await authenticateNotificationsRequest(request, env); - if (!payload?.sub) return errorResponse('Unauthorized', 401); if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') { return errorResponse('Expected websocket', 426); } + const payload = await authenticateNotificationsHub(request, env); + if (!payload?.sub) return errorResponse('Unauthorized', 401); const userId = payload.sub; const id = env.NOTIFICATIONS_HUB.idFromName(userId); diff --git a/src/utils/websocket-connection-token.ts b/src/utils/websocket-connection-token.ts new file mode 100644 index 0000000..3764bd6 --- /dev/null +++ b/src/utils/websocket-connection-token.ts @@ -0,0 +1,86 @@ +import { generateUUID } from './uuid'; + +const WEBSOCKET_NOTIFICATION_SCOPE = 'notifications.websocket'; + +export interface WebSocketConnectionTokenClaims { + userId: string; + expiresAt: number; + nonce: string; + scope: typeof WEBSOCKET_NOTIFICATION_SCOPE; +} + +function base64UrlEncode(data: Uint8Array): string { + return btoa(String.fromCharCode(...data)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function base64UrlDecode(value: string): Uint8Array { + let normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + while (normalized.length % 4) normalized += '='; + const binary = atob(normalized); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +async function getSigningKey(secret: string): Promise { + return crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'] + ); +} + +export async function createWebSocketConnectionToken( + userId: string, + expiresAt: number, + secret: string +): Promise { + const claims: WebSocketConnectionTokenClaims = { + userId, + expiresAt, + nonce: generateUUID(), + scope: WEBSOCKET_NOTIFICATION_SCOPE, + }; + const payload = base64UrlEncode(new TextEncoder().encode(JSON.stringify(claims))); + const signature = await crypto.subtle.sign( + 'HMAC', + await getSigningKey(secret), + new TextEncoder().encode(payload) + ); + return `${payload}.${base64UrlEncode(new Uint8Array(signature))}`; +} + +export async function verifyWebSocketConnectionToken( + token: string, + secret: string +): Promise { + try { + if (!token || token.length > 1024) return null; + const parts = token.split('.'); + if (parts.length !== 2) return null; + const [payload, encodedSignature] = parts; + const valid = await crypto.subtle.verify( + 'HMAC', + await getSigningKey(secret), + base64UrlDecode(encodedSignature), + new TextEncoder().encode(payload) + ); + if (!valid) return null; + + const claims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))) as Partial; + if ( + claims.scope !== WEBSOCKET_NOTIFICATION_SCOPE + || !String(claims.userId || '').trim() + || !Number.isFinite(claims.expiresAt) + || Number(claims.expiresAt) <= Date.now() + ) { + return null; + } + return claims as WebSocketConnectionTokenClaims; + } catch { + return null; + } +} diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index c490578..8104258 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1674,17 +1674,25 @@ export default function App() { reconnectAttempts += 1; reconnectTimer = window.setTimeout(() => { reconnectTimer = null; - connect(); + void connect(); }, delay); }; - const connect = () => { + const connect = async () => { if (disposed) return; const accessToken = session.accessToken; if (!accessToken) return; try { + const negotiateResponse = await fetch('/notifications/hub/negotiate?negotiateVersion=1', { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!negotiateResponse.ok) throw new Error('Notification negotiation failed'); + const negotiation = (await negotiateResponse.json()) as { connectionToken?: string }; + if (!negotiation.connectionToken || disposed) throw new Error('Notification connection token missing'); + const hubUrl = new URL('/notifications/hub', window.location.origin); - hubUrl.searchParams.set('access_token', accessToken); + hubUrl.searchParams.set('id', negotiation.connectionToken); hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:'; socket = new WebSocket(hubUrl.toString()); } catch { @@ -1809,7 +1817,7 @@ export default function App() { }); }; - connect(); + void connect(); return () => { disposed = true;