🧼 fix: Sanitize User Response Fields (#13421)

This commit is contained in:
Danny Avila 2026-05-30 19:35:52 -04:00 committed by GitHub
parent 479e9d59b7
commit 75baa5b848
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 137 additions and 9 deletions

View file

@ -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) {

View file

@ -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(),