security: prevent websocket JWT leakage through URL query tokens (#349)

* security: prevent websocket JWT query authentication

* fix: harden websocket connection tickets

* fix: expire unused websocket tickets
This commit is contained in:
Chius
2026-08-30 01:56:31 +08:00
committed by GitHub
parent df6b0b9767
commit e63f9663e8
6 changed files with 439 additions and 24 deletions
+1
View File
@@ -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",
+183
View File
@@ -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<string, { userId: string; deviceIdentifier: string | null; expiresAt: number }>();
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<Response> {
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<string> {
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, []);
});
+75
View File
@@ -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<Env> {
async fetch(request: Request): Promise<Response> {
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<WebSocketConnectionToken>(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<Env> {
});
}
async alarm(): Promise<void> {
const now = Date.now();
const tokens = await this.ctx.storage.list<WebSocketConnectionToken>({
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<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!attachment) return;
+75 -13
View File
@@ -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<JWTPayload | null> {
async function authenticateAccessToken(request: Request, env: Env): Promise<JWTPayload | null> {
const accessToken = extractAccessToken(request);
if (!accessToken) return null;
@@ -23,14 +25,71 @@ async function authenticateNotificationsRequest(request: Request, env: Env): Pro
return auth.verifyAccessToken(`Bearer ${accessToken}`);
}
async function issueWebSocketConnectionToken(payload: JWTPayload, env: Env): Promise<string> {
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<JWTPayload | null> {
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<JWTPayload | null> {
// 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<Response> {
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,
const connectionToken = await issueWebSocketConnectionToken(payload, env);
return jsonResponse(
{
connectionId: generateUUID(),
connectionToken,
negotiateVersion: 1,
availableTransports: [
{
@@ -38,15 +97,18 @@ export async function handleNotificationsNegotiate(request: Request, env: Env):
transferFormats: ['Text', 'Binary'],
},
],
});
},
200,
{ 'Cache-Control': 'no-store' }
);
}
export async function handleNotificationsHub(request: Request, env: Env): Promise<Response> {
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);
+86
View File
@@ -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<CryptoKey> {
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<string> {
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<WebSocketConnectionTokenClaims | null> {
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<WebSocketConnectionTokenClaims>;
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;
}
}
+12 -4
View File
@@ -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;