From 75baa5b8483982823bbfc80dea0f227a8e4a66d0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 30 May 2026 19:35:52 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=BC=20fix:=20Sanitize=20User=20Respons?= =?UTF-8?q?e=20Fields=20(#13421)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/controllers/UserController.js | 40 +++++-- api/server/controllers/UserController.spec.js | 106 +++++++++++++++++- 2 files changed, 137 insertions(+), 9 deletions(-) diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index 340ebdbb4c..ca560389e6 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -25,6 +25,37 @@ const { getAppConfig } = require('~/server/services/Config'); const { getLogStores } = require('~/cache'); const db = require('~/models'); +const PUBLIC_USER_RESPONSE_FIELDS = [ + '_id', + 'id', + 'name', + 'username', + 'email', + 'emailVerified', + 'avatar', + 'provider', + 'role', + 'plugins', + 'twoFactorEnabled', + 'termsAccepted', + 'personalization', + 'favorites', + 'skillStates', + 'createdAt', + 'updatedAt', + 'tenantId', +]; + +const sanitizeUserForResponse = (user) => { + const source = user.toObject != null ? user.toObject() : user; + return PUBLIC_USER_RESPONSE_FIELDS.reduce((userData, field) => { + if (source[field] !== undefined) { + userData[field] = source[field]; + } + return userData; + }, {}); +}; + const getUserController = async (req, res) => { const appConfig = req.config ?? @@ -34,14 +65,7 @@ const getUserController = async (req, res) => { tenantId: req.user?.tenantId, })); /** @type {IUser} */ - const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user }; - /** - * These fields should not exist due to secure field selection, but deletion - * is done in case of alternate database incompatibility with Mongo API - * */ - delete userData.password; - delete userData.totpSecret; - delete userData.backupCodes; + const userData = sanitizeUserForResponse(req.user); if (appConfig.fileStrategy === FileSources.s3 && userData.avatar) { const avatarNeedsRefresh = needsRefresh(userData.avatar, 3600); if (!avatarNeedsRefresh) { diff --git a/api/server/controllers/UserController.spec.js b/api/server/controllers/UserController.spec.js index 5e1419bde3..4dd2efaa7b 100644 --- a/api/server/controllers/UserController.spec.js +++ b/api/server/controllers/UserController.spec.js @@ -108,10 +108,114 @@ afterEach(async () => { } }); -const { deleteUserController } = require('./UserController'); +const { deleteUserController, getUserController } = require('./UserController'); const { Group } = require('~/db/models'); const { deleteConvos } = require('~/models'); +describe('getUserController', () => { + const mockRes = { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should only expose public user response fields from the request user', async () => { + const createdAt = new Date('2026-01-01T00:00:00.000Z'); + const updatedAt = new Date('2026-01-02T00:00:00.000Z'); + const req = { + config: {}, + user: { + id: 'user-id', + _id: 'user-id', + name: 'OpenID User', + username: 'openid-user', + email: 'openid@test.com', + emailVerified: true, + avatar: '/avatars/user-id.png', + provider: 'openid', + role: 'USER', + plugins: ['web_search'], + twoFactorEnabled: true, + termsAccepted: true, + personalization: { memories: false }, + favorites: [{ model: 'gpt-5', endpoint: 'openAI' }], + skillStates: { skill_one: true }, + createdAt, + updatedAt, + tenantId: 'tenant-id', + password: 'hashed-password', + __v: 1, + totpSecret: 'totp-secret', + backupCodes: [{ codeHash: 'backup-code' }], + pendingTotpSecret: 'pending-totp-secret', + pendingBackupCodes: [{ codeHash: 'pending-backup-code' }], + refreshToken: [{ refreshToken: 'legacy-refresh-token' }], + googleId: 'google-id', + openidId: 'openid-id', + openidIssuer: 'openid-issuer', + idOnTheSource: 'external-source-id', + federatedTokens: { + access_token: 'access-token', + id_token: 'id-token', + refresh_token: 'refresh-token', + }, + openidTokens: { + access_token: 'openid-access-token', + refresh_token: 'openid-refresh-token', + }, + tokenset: { + access_token: 'tokenset-access-token', + refresh_token: 'tokenset-refresh-token', + }, + safeLookingRuntimeField: 'internal-value', + }, + }; + + await getUserController(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + const sentUser = mockRes.send.mock.calls[0][0]; + expect(sentUser).toMatchObject({ + id: 'user-id', + _id: 'user-id', + name: 'OpenID User', + username: 'openid-user', + email: 'openid@test.com', + emailVerified: true, + avatar: '/avatars/user-id.png', + provider: 'openid', + role: 'USER', + plugins: ['web_search'], + twoFactorEnabled: true, + termsAccepted: true, + personalization: { memories: false }, + favorites: [{ model: 'gpt-5', endpoint: 'openAI' }], + skillStates: { skill_one: true }, + createdAt, + updatedAt, + tenantId: 'tenant-id', + }); + expect(sentUser).not.toHaveProperty('password'); + expect(sentUser).not.toHaveProperty('__v'); + expect(sentUser).not.toHaveProperty('totpSecret'); + expect(sentUser).not.toHaveProperty('backupCodes'); + expect(sentUser).not.toHaveProperty('pendingTotpSecret'); + expect(sentUser).not.toHaveProperty('pendingBackupCodes'); + expect(sentUser).not.toHaveProperty('refreshToken'); + expect(sentUser).not.toHaveProperty('googleId'); + expect(sentUser).not.toHaveProperty('openidId'); + expect(sentUser).not.toHaveProperty('openidIssuer'); + expect(sentUser).not.toHaveProperty('idOnTheSource'); + expect(sentUser).not.toHaveProperty('federatedTokens'); + expect(sentUser).not.toHaveProperty('openidTokens'); + expect(sentUser).not.toHaveProperty('tokenset'); + expect(sentUser).not.toHaveProperty('safeLookingRuntimeField'); + }); +}); + describe('deleteUserController', () => { const mockRes = { status: jest.fn().mockReturnThis(),