mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-09-22 04:30:12 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,30 +25,90 @@ 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,
|
||||
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<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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user