From cb0b2e9b4afc3aea39b65749619e21dfe04bb81b Mon Sep 17 00:00:00 2001 From: PiEgg Date: Tue, 19 May 2026 11:02:58 +0800 Subject: [PATCH] fix(plugin-auth): harden ws:message:auth:token handler against unhandled rejections (#9514) --- .../__tests__/ws-auth-token-handler.test.ts | 95 +++++++++++++++++++ .../plugin-auth/src/server/plugin.ts | 45 ++++++--- 2 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-auth/src/server/__tests__/ws-auth-token-handler.test.ts diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/ws-auth-token-handler.test.ts b/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/ws-auth-token-handler.test.ts new file mode 100644 index 00000000000..006d226669a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-auth/src/server/__tests__/ws-auth-token-handler.test.ts @@ -0,0 +1,95 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { MockServer, createMockServer } from '@nocobase/test'; + +// Regression coverage for the `ws:message:auth:token` handler in +// plugin-auth's server entry. The handler is registered with `app.on`, which +// is Node's sync EventEmitter — any rejection thrown inside the async +// listener becomes an unhandled promise rejection and (under Node's default +// policy) crashes the process. These cases exercise the failure paths that +// were known to crash before the handler grew defensive try/catch coverage. + +type RemoveTagPayload = { clientId: string; tagKey: string }; + +function nextRemoveTag(app: MockServer): Promise { + return new Promise((resolve) => { + app.once('ws:removeTag', resolve); + }); +} + +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return Promise.race([ + promise, + new Promise((_resolve, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)), + ]); +} + +describe('ws:message:auth:token handler', () => { + let app: MockServer; + + beforeEach(async () => { + app = await createMockServer({ + plugins: ['field-sort', 'users', 'auth'], + }); + }); + + afterEach(async () => { + await app.destroy(); + }); + + it('removes the userId tag when the payload has no token', async () => { + const removeTag = nextRemoveTag(app); + app.emit('ws:message:auth:token', { + clientId: 'client-no-token', + payload: { token: '' }, + }); + const result = await withTimeout(removeTag, 2000, 'ws:removeTag for empty token'); + expect(result.clientId).toBe('client-no-token'); + expect(result.tagKey).toBe('userId'); + }); + + it('removes the userId tag when the authenticator name is not registered in the DB', async () => { + // No authenticator row was seeded with this name, so `authManager.get` + // throws `Authenticator [no-such-authenticator] is not found.`. Before + // the fix this rejection escaped the async listener and crashed Node. + const removeTag = nextRemoveTag(app); + app.emit('ws:message:auth:token', { + clientId: 'client-bad-authenticator', + payload: { token: 'irrelevant', authenticator: 'no-such-authenticator' }, + }); + const result = await withTimeout(removeTag, 2000, 'ws:removeTag for missing authenticator'); + expect(result.clientId).toBe('client-bad-authenticator'); + expect(result.tagKey).toBe('userId'); + }); + + it('removes the userId tag when the authenticator references an unregistered authType', async () => { + // Seed an authenticator whose `authType` (`CAS`) no plugin in this app + // has registered — this is the production-observed shape: a DB row left + // over from when an auth plugin was previously enabled. Resolving it + // throws `AuthType [CAS] is not found.` from AuthManager. + await app.db.getRepository('authenticators').create({ + values: { + name: 'stranded-cas', + authType: 'CAS', + enabled: true, + options: {}, + }, + }); + + const removeTag = nextRemoveTag(app); + app.emit('ws:message:auth:token', { + clientId: 'client-unregistered-authtype', + payload: { token: 'irrelevant', authenticator: 'stranded-cas' }, + }); + const result = await withTimeout(removeTag, 2000, 'ws:removeTag for unregistered authType'); + expect(result.clientId).toBe('client-unregistered-authtype'); + expect(result.tagKey).toBe('userId'); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts index 72277c1dc15..920ce093b18 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts @@ -150,18 +150,39 @@ export class PluginAuthServer extends Plugin { return; } - const auth = await this.app.authManager.get(payload.authenticator || 'basic', { - getBearerToken: () => payload.token, - app: this.app, - db: this.app.db, - cache: this.app.cache, - logger: this.app.logger, - log: this.app.log, - throw: (...args) => { - throw new Error(...args); - }, - t: this.app.i18n.t, - } as any); + // `app.emit` is Node's sync EventEmitter, so any rejection thrown + // inside this async listener becomes an unhandled promise rejection — + // under Node 22's default policy that crashes the entire process. + // Resolving the authenticator can fail for legitimate runtime reasons + // (the auth-type plugin is disabled or still loading during boot, the + // authenticator row was deleted, etc.); none of those should take the + // server down. Wrap the lookup, log, drop the connection's userId tag + // so the client is treated as unauthenticated, and return. + let auth; + try { + auth = await this.app.authManager.get(payload.authenticator || 'basic', { + getBearerToken: () => payload.token, + app: this.app, + db: this.app.db, + cache: this.app.cache, + logger: this.app.logger, + log: this.app.log, + throw: (...args) => { + throw new Error(...args); + }, + t: this.app.i18n.t, + } as any); + } catch (error) { + this.app.logger.warn('ws:message:auth:token authenticator resolve failed', { + authenticator: payload.authenticator, + error: error instanceof Error ? error.message : String(error), + }); + this.app.emit(`ws:removeTag`, { + clientId, + tagKey: 'userId', + }); + return; + } let user: Model; try {