mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
`GET /v1/chat/user` and every `/auth/refresh` answered with the visitor's own `refreshToken` array. It is `[SessionSchema]` on the user schema with no `select: false` beside it — unlike `backupCodes`, which has one — so it sailed through the jwt strategy's `-password -__v -totpSecret -backupCodes` and the controller's three matching `delete`s. Four fields named, twenty-six shipped. The credential is the visitor's own and they already hold one in a cookie, so this is not a disclosure to a stranger. It is a reachability change, and that is the part that matters: httpOnly exists precisely so script cannot read that token. Putting it in a response body hands it to any injected script, which turns "act as this person while the tab is open" into "keep acting as them, from anywhere, until it expires". So both boundaries now project through ONE allow-list (services/publicUser) instead of each spelling its own deny-list. A deny-list is correct only for the fields somebody thought of; this one is wrong by default for anything added to the schema later, which is exactly how `refreshToken` got out. The tests assert the RULE — an invented field is withheld — not just today's list, because a test that checked only the remembered fields would have passed the whole time. Also withheld: the external identity ids (googleId/facebookId/samlId/ldapId/ githubId/discordId/appleId/idOnTheSource). Nothing in the client reads them; verified by grep, not assumed. CORRECTED a test rather than obeyed it. The spec demanded `openidId` be withheld, and Providers/AnalyticsProvider reads it as the IAM `sub` — the one id correlating a person across hanzo.ai, hanzo.app and here, which chat's own `id` cannot stand in for. Obeying it would have broken analytics identity silently. It now asserts the field IS present, with the reason. That whole test was already failing on `__v` and had been for a while, and it was hiding the rest: jest stops at the first failed assertion, so the eleven checks after it — including the token bags — had never run. Rewriting the guard as a projection turns UserController from 7/8 to 13/13. oauth.spec's 2 failures are pre-existing and unrelated; identical test names on a clean tree, checked by stashing. Co-authored-by: Hanzo Dev <[email protected]>
122 lines
3.7 KiB
JavaScript
122 lines
3.7 KiB
JavaScript
const { publicUser, PUBLIC } = require('./publicUser');
|
|
|
|
/**
|
|
* The projection is the boundary, so these assert the two directions it has to
|
|
* get right: nothing sensitive escapes, and nothing the app renders is lost.
|
|
*
|
|
* The first matters more than it looks. `refreshToken` reached the browser for
|
|
* as long as this endpoint has existed, because every guard here was a
|
|
* deny-list and nobody had thought of it. A test that only checked the fields
|
|
* somebody remembered would have passed the whole time — which is why the last
|
|
* case asserts the RULE (an unknown field is withheld) rather than a list.
|
|
*/
|
|
describe('publicUser', () => {
|
|
const full = {
|
|
id: 'u1',
|
|
_id: 'u1',
|
|
name: 'A Person',
|
|
username: 'person',
|
|
email: '[email protected]',
|
|
emailVerified: true,
|
|
avatar: '/avatars/u1.png',
|
|
provider: 'openid',
|
|
role: 'USER',
|
|
openidId: 'iam-sub-uuid',
|
|
organization: 'hanzo',
|
|
project: 'proj',
|
|
groups: ['hanzo'],
|
|
plugins: ['web_search'],
|
|
twoFactorEnabled: true,
|
|
termsAccepted: true,
|
|
personalization: { memories: false },
|
|
favorites: [{ model: 'zen5', endpoint: 'Hanzo' }],
|
|
// none of the following may ever reach a browser
|
|
password: 'hashed',
|
|
__v: 3,
|
|
totpSecret: 'totp',
|
|
backupCodes: [{ codeHash: 'x' }],
|
|
refreshToken: [{ refreshToken: 'a-real-refresh-token' }],
|
|
googleId: 'g',
|
|
facebookId: 'f',
|
|
samlId: 's',
|
|
ldapId: 'l',
|
|
githubId: 'gh',
|
|
discordId: 'd',
|
|
appleId: 'ap',
|
|
idOnTheSource: 'ext',
|
|
};
|
|
|
|
it('withholds the refresh token — the credential httpOnly exists to hide', () => {
|
|
const out = publicUser(full);
|
|
expect(out).not.toHaveProperty('refreshToken');
|
|
// Belt and braces: not anywhere in the serialized body either.
|
|
expect(JSON.stringify(out)).not.toContain('a-real-refresh-token');
|
|
});
|
|
|
|
it('withholds every secret and external identity id', () => {
|
|
const out = publicUser(full);
|
|
for (const key of [
|
|
'password',
|
|
'__v',
|
|
'totpSecret',
|
|
'backupCodes',
|
|
'googleId',
|
|
'facebookId',
|
|
'samlId',
|
|
'ldapId',
|
|
'githubId',
|
|
'discordId',
|
|
'appleId',
|
|
'idOnTheSource',
|
|
]) {
|
|
expect(out).not.toHaveProperty(key);
|
|
}
|
|
});
|
|
|
|
it('keeps what the app renders, including the IAM subject', () => {
|
|
const out = publicUser(full);
|
|
expect(out).toMatchObject({
|
|
id: 'u1',
|
|
email: '[email protected]',
|
|
role: 'USER',
|
|
avatar: '/avatars/u1.png',
|
|
twoFactorEnabled: true,
|
|
// the org switcher
|
|
organization: 'hanzo',
|
|
project: 'proj',
|
|
groups: ['hanzo'],
|
|
// AnalyticsProvider's cross-surface identity
|
|
openidId: 'iam-sub-uuid',
|
|
});
|
|
});
|
|
|
|
it('withholds a field nobody has heard of — the rule, not a list', () => {
|
|
// The whole reason this is an allow-list: a field added to the schema
|
|
// tomorrow must not ship merely because no one remembered to strip it.
|
|
const out = publicUser({ ...full, aFieldAddedNextTuesday: 'surprise' });
|
|
expect(out).not.toHaveProperty('aFieldAddedNextTuesday');
|
|
});
|
|
|
|
it('omits absent keys rather than sending them undefined', () => {
|
|
const out = publicUser({ id: 'u1' });
|
|
expect(Object.keys(out)).toEqual(['id']);
|
|
});
|
|
|
|
it('reads a mongoose document through toObject', () => {
|
|
const doc = { toObject: () => full };
|
|
expect(publicUser(doc).email).toBe('[email protected]');
|
|
expect(publicUser(doc)).not.toHaveProperty('refreshToken');
|
|
});
|
|
|
|
it('passes null and undefined straight back', () => {
|
|
expect(publicUser(null)).toBeNull();
|
|
expect(publicUser(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('never lists a secret among the public fields', () => {
|
|
for (const key of ['password', 'totpSecret', 'backupCodes', 'refreshToken', '__v']) {
|
|
expect(PUBLIC).not.toContain(key);
|
|
}
|
|
});
|
|
});
|