Files
nodewarden/src/handlers/notifications.ts
T
Chius e63f9663e8 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
2026-08-30 01:56:31 +08:00

144 lines
5.5 KiB
TypeScript

import { AuthService } from '../services/auth';
import { StorageService } from '../services/storage';
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 authHeader = String(request.headers.get('Authorization') || '').trim();
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || null;
}
async function authenticateAccessToken(request: Request, env: Env): Promise<JWTPayload | null> {
const accessToken = extractAccessToken(request);
if (!accessToken) return null;
const auth = new AuthService(env);
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 authenticateAccessToken(request, env);
if (!payload?.sub) return errorResponse('Unauthorized', 401);
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> {
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);
const stub = env.NOTIFICATIONS_HUB.get(id);
const forwardedUrl = new URL(request.url);
forwardedUrl.searchParams.set('nw_uid', userId);
if (payload.did) {
forwardedUrl.searchParams.set('nw_did', payload.did);
}
return stub.fetch(new Request(forwardedUrl.toString(), request));
}
export async function handleAnonymousNotificationsHub(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const authRequestId = String(url.searchParams.get('Token') || url.searchParams.get('token') || '').trim();
if (!authRequestId) return errorResponse('Token is required', 400);
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return errorResponse('Expected websocket', 426);
}
const storage = new StorageService(env.DB);
const authRequest = await storage.getAuthRequestById(authRequestId);
if (!authRequest || isAuthRequestExpired(authRequest)) {
return errorResponse('Not found', 404);
}
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
const stub = env.NOTIFICATIONS_HUB.get(id);
const forwardedUrl = new URL(request.url);
forwardedUrl.searchParams.set('nw_auth_request_id', authRequestId);
return stub.fetch(new Request(forwardedUrl.toString(), request));
}