Compare commits

..
Author SHA1 Message Date
dependabot[bot] eccd6294d0 chore(deps): bump the npm-minor-and-patch group across 1 directory with 11 updates
Bumps the npm-minor-and-patch group with 11 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.101.2` | `5.101.4` |
| [@zip.js/zip.js](https://github.com/gildas-lormeau/zip.js) | `2.8.26` | `2.8.34` |
| [lucide-preact](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-preact) | `1.22.0` | `1.27.0` |
| [preact](https://github.com/preactjs/preact) | `10.29.3` | `10.29.7` |
| [@preact/preset-vite](https://github.com/preactjs/preset-vite) | `2.10.5` | `2.10.6` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.0.1` | `26.1.1` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.5.2` | `10.5.4` |
| [opencc-js](https://github.com/nk2028/opencc-js) | `1.3.2` | `1.4.1` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.4` | `4.23.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.1.3` | `8.1.5` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.105.0` | `4.114.0` |



Updates `@tanstack/react-query` from 5.101.2 to 5.101.4
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.4/packages/react-query)

Updates `@zip.js/zip.js` from 2.8.26 to 2.8.34
- [Release notes](https://github.com/gildas-lormeau/zip.js/releases)
- [Commits](https://github.com/gildas-lormeau/zip.js/compare/v2.8.26...v2.8.34)

Updates `lucide-preact` from 1.22.0 to 1.27.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.27.0/packages/lucide-preact)

Updates `preact` from 10.29.3 to 10.29.7
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.29.3...10.29.7)

Updates `@preact/preset-vite` from 2.10.5 to 2.10.6
- [Release notes](https://github.com/preactjs/preset-vite/releases)
- [Changelog](https://github.com/preactjs/preset-vite/blob/main/CHANGELOG.md)
- [Commits](https://github.com/preactjs/preset-vite/commits)

Updates `@types/node` from 26.0.1 to 26.1.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `autoprefixer` from 10.5.2 to 10.5.4
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.5.2...10.5.4)

Updates `opencc-js` from 1.3.2 to 1.4.1
- [Release notes](https://github.com/nk2028/opencc-js/releases)
- [Changelog](https://github.com/nk2028/opencc-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nk2028/opencc-js/compare/v1.3.2...v1.4.1)

Updates `tsx` from 4.22.4 to 4.23.1
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.4...v4.23.1)

Updates `vite` from 8.1.3 to 8.1.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.5/packages/vite)

Updates `wrangler` from 4.105.0 to 4.114.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.114.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: "@preact/preset-vite"
  dependency-version: 2.10.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 26.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@zip.js/zip.js"
  dependency-version: 2.8.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: autoprefixer
  dependency-version: 10.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: lucide-preact
  dependency-version: 1.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: opencc-js
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: preact
  dependency-version: 10.29.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: tsx
  dependency-version: 4.23.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.114.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-26 21:04:53 +00:00
18 changed files with 399 additions and 820 deletions
-2
View File
@@ -44,7 +44,6 @@ tmp/
.tmp-bitwarden-clients/
nodewarden-wiki/
nodewarden.wiki/
wiki/
AGENTS.md
settings.json
@@ -58,7 +57,6 @@ NodeWarden-compat/
.reasonix/
.upstream/
bitwarden-upstream/
# Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md
+326 -292
View File
File diff suppressed because it is too large Load Diff
+14 -16
View File
@@ -17,7 +17,6 @@
"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",
@@ -49,8 +48,7 @@
}
},
"overrides": {
"nanoid": "3.3.18",
"undici": "8.9.0",
"undici": ">=7.28.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1",
"ws": "8.21.0",
@@ -59,26 +57,26 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20260630.1",
"@preact/preset-vite": "^2.10.6",
"@types/node": "^26.5.1",
"autoprefixer": "^10.5.6",
"opencc-js": "^1.4.2",
"postcss": "^8.5.28",
"@types/node": "^26.1.1",
"autoprefixer": "^10.5.4",
"opencc-js": "^1.4.1",
"postcss": "^8.5.23",
"tailwindcss": "^3.4.19",
"tsx": "^4.23.13",
"tsx": "^4.23.1",
"typescript": "^6.0.3",
"vite": "^8.3.0",
"wrangler": "^4.131.0"
"vite": "^8.1.5",
"wrangler": "^4.114.0"
},
"dependencies": {
"@noble/hashes": "^2.4.0",
"@noble/hashes": "^2.2.0",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "^5.102.8",
"@zip.js/zip.js": "^2.14.0",
"@tanstack/react-query": "^5.101.4",
"@zip.js/zip.js": "^2.8.34",
"fflate": "^0.8.3",
"jsqr": "1.4.0",
"lucide-preact": "^1.44.0",
"preact": "^10.29.8",
"lucide-preact": "^1.27.0",
"preact": "^10.29.7",
"qrcode-generator": "^2.0.4",
"wouter": "^3.11.0"
"wouter": "^3.10.0"
}
}
-183
View File
@@ -1,183 +0,0 @@
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,8 +19,6 @@ 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';
@@ -34,12 +32,6 @@ 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);
@@ -215,52 +207,6 @@ 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;
@@ -355,27 +301,6 @@ 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;
-32
View File
@@ -1,32 +0,0 @@
interface AliasedValue<T> {
present: boolean;
value: T | null | undefined;
}
function readOwnAliasedValue<T>(source: unknown, aliases: readonly string[]): AliasedValue<T> {
if (!source || typeof source !== 'object') {
return { present: false, value: undefined };
}
const record = source as Record<string, unknown>;
for (const alias of aliases) {
if (Object.prototype.hasOwnProperty.call(record, alias)) {
return { present: true, value: record[alias] as T | null | undefined };
}
}
return { present: false, value: undefined };
}
/**
* Full cipher updates use replacement semantics for nullable fields.
* Bitwarden clients may omit a property after its value is cleared, so an
* absent property must become null instead of falling back to stored data.
*/
export function readNullableFullUpdateField<T>(
source: unknown,
aliases: readonly string[]
): T | null {
const incoming = readOwnAliasedValue<T>(source, aliases);
return incoming.present ? incoming.value ?? null : null;
}
+10 -5
View File
@@ -27,7 +27,6 @@ import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from '.
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { readNullableFullUpdateField } from './cipher-full-update';
// CONTRACT:
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
@@ -1101,10 +1100,16 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
}
// Nullable fields use replacement semantics on this full-update endpoint.
// Some clients omit cleared values, so merge fallback must not resurrect them.
cipher.notes = readNullableFullUpdateField<string>(cipherData, ['notes', 'Notes']);
cipher.fields = readNullableFullUpdateField<Cipher['fields']>(cipherData, ['fields', 'Fields']);
// Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields".
// - For full update (PUT/POST on this endpoint), missing fields means cleared fields.
// This prevents stale custom fields from being resurrected by merge fallback.
const incomingFields = getAliasedProp(cipherData, ['fields', 'Fields']);
if (incomingFields.present) {
cipher.fields = incomingFields.value ?? null;
} else if (request.method === 'PUT' || request.method === 'POST') {
cipher.fields = null;
}
normalizeCipherForStorage(cipher);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) return errorResponse(compatibilityError, 400);
+20 -82
View File
@@ -4,20 +4,18 @@ 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 authenticateAccessToken(request: Request, env: Env): Promise<JWTPayload | null> {
async function authenticateNotificationsRequest(request: Request, env: Env): Promise<JWTPayload | null> {
const accessToken = extractAccessToken(request);
if (!accessToken) return null;
@@ -25,90 +23,30 @@ async function authenticateAccessToken(request: Request, env: Env): Promise<JWTP
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);
const payload = await authenticateNotificationsRequest(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' }
);
const connectionId = generateUUID();
return jsonResponse({
connectionId,
connectionToken: connectionId,
negotiateVersion: 1,
availableTransports: [
{
transport: 'WebSockets',
transferFormats: ['Text', 'Binary'],
},
],
});
}
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
@@ -1,86 +0,0 @@
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;
}
}
+4 -12
View File
@@ -1674,25 +1674,17 @@ export default function App() {
reconnectAttempts += 1;
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
void connect();
connect();
}, delay);
};
const connect = async () => {
const connect = () => {
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('id', negotiation.connectionToken);
hubUrl.searchParams.set('access_token', accessToken);
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(hubUrl.toString());
} catch {
@@ -1817,7 +1809,7 @@ export default function App() {
});
};
void connect();
connect();
return () => {
disposed = true;
+1 -3
View File
@@ -382,9 +382,7 @@ export default function VaultPage(props: VaultPageProps) {
}
const groupIndexByKey = new Map<string, number>();
Array.from(groupKeys).sort().forEach((groupKey, index) => {
// Keep indices unique (no modulo): they are used both for group colors and
// as the group identity when selecting duplicate items to delete.
groupIndexByKey.set(groupKey, index);
groupIndexByKey.set(groupKey, index % 64);
});
const byId = new Map<string, number>();
for (const [cipherId, groupKey] of groupKeyById.entries()) {
+1 -1
View File
@@ -197,7 +197,7 @@ export async function downloadAdminBackupAttachmentBlob(
authedFetch: AuthedFetch,
blobName: string,
masterPasswordHash: string
): Promise<Uint8Array<ArrayBuffer>> {
): Promise<Uint8Array> {
const resp = await authedFetch('/api/admin/backup/blob', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+1 -1
View File
@@ -186,7 +186,7 @@ export function invalidateRemoteBrowserCacheForDestination(
cache: Record<string, RemoteBackupBrowserResponse>,
pathByDestination: Record<string, string>,
pageByKey: Record<string, number>
): Omit<PersistedRemoteBrowserState, 'refreshedAt'> {
): PersistedRemoteBrowserState {
return {
cache: Object.fromEntries(Object.entries(cache).filter(([key]) => !key.startsWith(`${destinationId}:`))),
pathByDestination: Object.fromEntries(Object.entries(pathByDestination).filter(([key]) => key !== destinationId)),
+2 -6
View File
@@ -23,7 +23,7 @@ function createState(fingerprint: string): InternalPasswordSecurityState {
return { fingerprint, report: null, scannedAt: null, scanning: false, progress: { checked: 0, total: 0 }, scanError: false, controller: null };
}
function ensurePasswordSecurityState(fingerprint: string): InternalPasswordSecurityState {
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
if (state?.fingerprint !== fingerprint) {
state?.controller?.abort();
state = createState(fingerprint);
@@ -31,10 +31,6 @@ function ensurePasswordSecurityState(fingerprint: string): InternalPasswordSecur
return state;
}
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
return ensurePasswordSecurityState(fingerprint);
}
export function readPasswordSecurityState(fingerprint: string): PasswordSecurityState | null {
return state?.fingerprint === fingerprint ? state : null;
}
@@ -45,7 +41,7 @@ export function subscribePasswordSecurityState(listener: () => void): () => void
}
export function startPasswordSecurityScan(fingerprint: string, ciphers: Cipher[]): void {
const current = ensurePasswordSecurityState(fingerprint);
const current = getPasswordSecurityState(fingerprint);
current.controller?.abort();
const controller = new AbortController();
const total = ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length;
+4 -4
View File
@@ -369,7 +369,7 @@
.not-found-code {
@apply rounded-full px-3 py-1 text-sm font-extrabold;
background: #eef4ff;
color: var(--primary-hover);
color: #1d4ed8;
}
.not-found-copy {
@@ -574,7 +574,7 @@
.jwt-inline-link {
@apply font-bold no-underline;
color: var(--primary-hover);
color: #1d4ed8;
}
.jwt-inline-link:hover {
@@ -613,7 +613,7 @@
.standalone-footer a {
@apply font-bold no-underline;
color: var(--primary-hover);
color: #1d4ed8;
}
.standalone-footer a:hover {
@@ -622,5 +622,5 @@
.standalone-version {
@apply font-bold;
color: var(--primary-hover);
color: #1d4ed8;
}
+1 -1
View File
@@ -220,7 +220,7 @@
}
:root[data-theme='dark'] .card-brand-icon {
color: var(--primary-strong);
color: #bfdbfe;
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
border-color: color-mix(in srgb, var(--primary) 30%, var(--line));
}
+10 -12
View File
@@ -99,7 +99,7 @@
@apply inline-flex h-[22px] w-[22px] shrink-0 cursor-pointer items-center justify-center rounded-full p-0 text-[13px] font-extrabold leading-none;
border: 1px solid #bfd1f3;
background: #eef4ff;
color: var(--primary-hover);
color: #1d4ed8;
}
.backup-help-trigger:hover,
@@ -194,7 +194,7 @@
}
.backup-recommendation-step a {
color: var(--primary-hover);
color: #1d4ed8;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
@@ -316,14 +316,14 @@
}
.backup-interval-preset:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
.backup-interval-preset.active {
border-color: var(--primary);
background: var(--primary);
border-color: #2563eb;
background: #2563eb;
color: #fff;
}
@@ -1786,7 +1786,7 @@
}
.restore-progress-item.active {
color: var(--primary-hover);
color: #1d4ed8;
}
.restore-progress-item.done {
@@ -1799,7 +1799,7 @@
}
.restore-progress-item.active .restore-progress-dot {
background: var(--primary-hover);
background: #1d4ed8;
}
.restore-progress-item.done .restore-progress-dot {
@@ -1871,7 +1871,7 @@
.authorized-device-checkbox {
width: 16px;
height: 16px;
accent-color: var(--primary);
accent-color: #2563eb;
}
.authorized-devices-table td:first-child {
@@ -1879,9 +1879,7 @@
}
.authorized-devices-actions {
/* Wrap instead of clipping: four non-shrinking buttons overflow the
fixed 26% actions column at common widths, hiding Delete entirely. */
flex-wrap: wrap;
flex-wrap: nowrap;
gap: 6px;
}
+5 -7
View File
@@ -47,7 +47,7 @@
}
.folder-add-btn:hover {
color: var(--primary-hover);
color: #1d4ed8;
}
.search-input {
@@ -161,7 +161,7 @@
}
.folder-sort-btn:hover {
color: var(--primary-hover);
color: #1d4ed8;
background: #dbeafe;
transform: scale(1.06);
}
@@ -230,7 +230,7 @@ select.input.duplicate-mode-toolbar-select {
}
.list-head {
@apply mb-1.5 flex flex-wrap items-center gap-2;
@apply mb-1.5 flex items-center gap-2;
min-height: 34px;
}
@@ -860,9 +860,7 @@ select.input.duplicate-mode-toolbar-select {
.kv-row {
@apply grid items-center gap-2.5 py-2.5;
/* Floor the value column so the auto-sized actions column wraps its
buttons instead of collapsing the value to 0 width. */
grid-template-columns: minmax(0px, 80px) minmax(min(35%, 140px), 1fr) auto;
grid-template-columns: minmax(0px, 80px) minmax(0, 1fr) auto;
border-bottom: 1px solid rgba(154, 172, 205, 0.22);
}
@@ -964,7 +962,7 @@ select.input.duplicate-mode-toolbar-select {
}
.totp-ring-progress {
stroke: var(--primary);
stroke: #2563eb;
stroke-linecap: round;
transition: stroke-dashoffset 260ms linear, stroke 200ms ease;
}