Files
nodewarden/scripts/notifications-security.test.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

184 lines
6.3 KiB
TypeScript

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, []);
});