Fix account info disclosure (#10874)

* Fix account info disclosure

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix translations

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-06-19 13:09:46 +07:00
committed by GitHub
parent f3ef8d2bab
commit 5e03566ea2
20 changed files with 163 additions and 39 deletions
+4
View File
@@ -274,6 +274,10 @@ export class TEmployee extends TPerson implements Employee {
position?: string | null
declare personUuid?: AccountUuid
@Prop(TypeString(), contact.string.Timezone)
@Hidden()
timezone?: string
}
@Model(contact.class.ContactsTab, core.class.Doc, DOMAIN_MODEL)
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "Uživatelský profil",
"DeactivatedAccount": "Deaktivovaný účet",
"LocalTime": "místní čas",
"Timezone": "Časové pásmo",
"Everyone": "Všichni",
"Here": "Zde",
"EveryoneDescription": "Upozornit všechny v tomto {title}",
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "Benutzerprofil",
"DeactivatedAccount": "Deaktivierter Account",
"LocalTime": "Ortszeit",
"Timezone": "Zeitzone",
"Everyone": "Jeder",
"Here": "Hier",
"EveryoneDescription": "Benachrichtigen Sie alle in diesem {title}",
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "User profile",
"DeactivatedAccount": "Deactivated account",
"LocalTime": "local time",
"Timezone": "Timezone",
"Everyone": "Everyone",
"Here": "Here",
"EveryoneDescription": "Notify everyone in this {title}",
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "Perfil de usuario",
"DeactivatedAccount": "Cuenta desactivada",
"LocalTime": "hora local",
"Timezone": "Zona horaria",
"Everyone": "Todos",
"Here": "Aquí",
"EveryoneDescription": "Notificar a todos en este {title}",
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "Profil utilisateur",
"DeactivatedAccount": "Compte désactivé",
"LocalTime": "heure locale",
"Timezone": "Fuseau horaire",
"Everyone": "Tout le monde",
"Here": "Ici",
"EveryoneDescription": "Notifier tout le monde dans ce {title}",
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "Profilo utente",
"DeactivatedAccount": "Account disattivato",
"LocalTime": "ora locale",
"Timezone": "Fuso orario",
"Everyone": "Tutti",
"Here": "Qui",
"EveryoneDescription": "Notifica tutti in questo {title}",
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "ユーザープロフィール",
"DeactivatedAccount": "アカウントは無効化されました",
"LocalTime": "現地時間",
"Timezone": "タイムゾーン",
"Everyone": "全員",
"Here": "ここ",
"EveryoneDescription": "この{title}のすべてに通知する",
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "사용자 프로필",
"DeactivatedAccount": "비활성화된 계정",
"LocalTime": "현지 시간",
"Timezone": "시간대",
"Everyone": "모두",
"Here": "여기",
"EveryoneDescription": "이 {title}의 모든 멤버에게 알림",
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "Perfil do usuário",
"DeactivatedAccount": "Conta desativada",
"LocalTime": "hora local",
"Timezone": "Fuso horário",
"Everyone": "Todos",
"Here": "Aqui",
"EveryoneDescription": "Notificar todos em {title}",
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "Perfil do usuário",
"DeactivatedAccount": "Conta desativada",
"LocalTime": "hora local",
"Timezone": "Fuso horário",
"Everyone": "Todos",
"Here": "Aqui",
"EveryoneDescription": "Notificar todos neste {title}",
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "Профиль пользователя",
"DeactivatedAccount": "Деактивированный аккаунт",
"LocalTime": "местного времени",
"Timezone": "Часовой пояс",
"Everyone": "Все",
"Here": "Здесь",
"EveryoneDescription": "Уведомить всех в этом {title}",
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "Kullanıcı profili",
"DeactivatedAccount": "Devre dışı hesap",
"LocalTime": "yerel saat",
"Timezone": "Saat dilimi",
"Everyone": "Herkes",
"Here": "Burada",
"EveryoneDescription": "Bu {title} içindeki herkesi bilgilendir",
+1
View File
@@ -118,6 +118,7 @@
"UserProfile": "用户资料",
"DeactivatedAccount": "已停用账户",
"LocalTime": "当地时间",
"Timezone": "时区",
"Everyone": "所有人",
"Here": "这里",
"EveryoneDescription": "通知所有人在此{title}",
@@ -29,7 +29,6 @@
import DeactivatedHeader from './DeactivatedHeader.svelte'
import ModernProfilePopup from './ModernProfilePopup.svelte'
import TimePresenter from './TimePresenter.svelte'
import { getPersonTimezone } from './utils'
export let _id: Ref<Employee>
export let disabled: boolean = false
@@ -40,13 +39,12 @@
const hierarchy = client.getHierarchy()
let employee: Employee | Person | undefined = undefined
let timezone: string | undefined = undefined
let isEmployee: boolean = false
$: personByRefStore = getPersonByPersonRefStore([_id])
$: employee = $employeeByIdStore.get(_id) ?? $personByRefStore.get(_id)
$: isEmployee = $employeeByIdStore.has(_id)
$: void loadPersonTimezone(employee)
$: timezone = isEmployee ? (employee as Employee | undefined)?.timezone : undefined
const levelQuery = createQuery()
@@ -69,12 +67,6 @@
navigate(loc)
}
async function loadPersonTimezone (person: Employee | Person | undefined): Promise<void> {
if (person?.personUuid !== undefined && isEmployee) {
timezone = await getPersonTimezone(person?.personUuid as AccountUuid)
}
}
$: statusSubtitle =
employee?.personUuid !== undefined
? getWorkspaceMemberStatusSubtitle($workspaceMemberStatusByAccountStore.get(employee.personUuid as AccountUuid))
@@ -10,16 +10,13 @@
//
// See the License for the specific language governing permissions and
// limitations under the License.
import { get, writable } from 'svelte/store'
import type { AccountUuid, Class, Ref } from '@hcengineering/core'
import type { Class, Ref } from '@hcengineering/core'
import type { Person } from '@hcengineering/contact'
import { getClient } from '@hcengineering/presentation'
import type { LabelAndProps } from '@hcengineering/ui'
import contact from '../../plugin'
import EmployeePreviewPopup from './EmployeePreviewPopup.svelte'
import { getAccountClient } from '../../utils'
const client = getClient()
const h = client.getHierarchy()
@@ -39,28 +36,3 @@ export function getPreviewPopup (
noArrow: true
}
}
export const timezoneByAccountStore = writable<Map<AccountUuid, string>>(new Map())
export async function getPersonTimezone (personId: AccountUuid | undefined): Promise<string | undefined> {
if (personId === undefined) return undefined
const storedTimezone = get(timezoneByAccountStore).get(personId)
if (storedTimezone !== undefined) return storedTimezone
try {
const accountInfo = await getAccountClient().getAccountInfo(personId)
if (accountInfo.timezone !== undefined) {
timezoneByAccountStore.update((store: Map<AccountUuid, string>) => {
if (accountInfo.timezone !== undefined) {
store.set(personId, accountInfo.timezone)
}
return store
})
}
return accountInfo.timezone
} catch (error) {
console.error(error)
return undefined
}
}
+33
View File
@@ -364,6 +364,39 @@ export const myEmployeeStore = derived(
}
)
function detectBrowserTimezone (): string | undefined {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
return tz !== '' ? tz : undefined
} catch {
return undefined
}
}
let timezoneSyncInFlight = false
async function syncMyEmployeeTimezone (employee: WithLookup<Employee> | undefined): Promise<void> {
if (timezoneSyncInFlight || employee === undefined) return
const browserTz = detectBrowserTimezone()
if (browserTz === undefined || employee.timezone === browserTz) return
timezoneSyncInFlight = true
try {
const client = getClient()
await client.updateMixin(employee._id, contact.class.Person, employee.space, contact.mixin.Employee, {
timezone: browserTz
})
} catch (err) {
console.error('Failed to sync employee timezone', err)
} finally {
timezoneSyncInFlight = false
}
}
myEmployeeStore.subscribe((employee) => {
void syncMyEmployeeTimezone(employee)
})
/**
* [Ref<Employee> => PersonId (primary)] mapping
*/
+2
View File
@@ -190,6 +190,7 @@ export interface Employee extends Person {
statuses?: number
position?: string | null
personUuid?: AccountUuid
timezone?: string
}
/**
@@ -385,6 +386,7 @@ export const contactPlugin = plugin(contactId, {
UserProfile: '' as IntlString,
DeactivatedAccount: '' as IntlString,
LocalTime: '' as IntlString,
Timezone: '' as IntlString,
Everyone: '' as IntlString,
Here: '' as IntlString,
EveryoneDescription: '' as IntlString,
@@ -49,6 +49,7 @@ import {
changePassword,
getPerson,
getSocialIds,
getAccountInfo,
createAccessLink,
getSubscriptions,
leaveWorkspace,
@@ -2881,6 +2882,103 @@ describe('account operations', () => {
)
})
})
describe('getAccountInfo', () => {
const callerAccount = 'caller-account-uuid' as AccountUuid
const otherAccount = 'other-account-uuid' as AccountUuid
const accountInfoDb = {
account: {
findOne: jest.fn()
}
} as unknown as AccountDB
beforeEach(() => {
jest.clearAllMocks()
})
test('returns own info when accountId matches caller', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: callerAccount, extra: {} })
;(accountInfoDb.account.findOne as jest.Mock).mockResolvedValue({
uuid: callerAccount,
timezone: 'America/New_York',
locale: 'en-US',
tfaSecret: 'secret'
})
const result = await getAccountInfo(mockCtx, accountInfoDb, mockBranding, mockToken, {
accountId: callerAccount
})
expect(result).toEqual({ timezone: 'America/New_York', locale: 'en-US', tfaEnabled: true })
expect(accountInfoDb.account.findOne).toHaveBeenCalledWith({ uuid: callerAccount })
})
test('rejects cross-account lookup for non-admin, non-service caller', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: callerAccount, extra: {} })
await expect(
getAccountInfo(mockCtx, accountInfoDb, mockBranding, mockToken, { accountId: otherAccount })
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
expect(accountInfoDb.account.findOne).not.toHaveBeenCalled()
})
test('allows admin to read other account info', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: callerAccount, extra: { admin: 'true' } })
;(accountInfoDb.account.findOne as jest.Mock).mockResolvedValue({
uuid: otherAccount,
timezone: 'Europe/London',
locale: 'en-GB',
tfaSecret: null
})
const result = await getAccountInfo(mockCtx, accountInfoDb, mockBranding, mockToken, {
accountId: otherAccount
})
expect(result).toEqual({ timezone: 'Europe/London', locale: 'en-GB', tfaEnabled: false })
expect(accountInfoDb.account.findOne).toHaveBeenCalledWith({ uuid: otherAccount })
})
test('allows workspace service token to read other account info', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
account: callerAccount,
extra: { service: 'workspace' }
})
;(accountInfoDb.account.findOne as jest.Mock).mockResolvedValue({
uuid: otherAccount,
timezone: 'Asia/Tokyo',
locale: 'ja-JP',
tfaSecret: null
})
const result = await getAccountInfo(mockCtx, accountInfoDb, mockBranding, mockToken, {
accountId: otherAccount
})
expect(result).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', tfaEnabled: false })
})
test('throws BadRequest when accountId is missing', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: callerAccount, extra: {} })
await expect(
getAccountInfo(mockCtx, accountInfoDb, mockBranding, mockToken, { accountId: '' as AccountUuid })
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {})))
expect(accountInfoDb.account.findOne).not.toHaveBeenCalled()
})
test('throws AccountNotFound when account does not exist', async () => {
;(decodeTokenVerbose as jest.Mock).mockReturnValue({ account: callerAccount, extra: {} })
;(accountInfoDb.account.findOne as jest.Mock).mockResolvedValue(null)
await expect(
getAccountInfo(mockCtx, accountInfoDb, mockBranding, mockToken, { accountId: callerAccount })
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {})))
})
})
})
describe('checkJoin', () => {
+11 -1
View File
@@ -2491,7 +2491,17 @@ export async function getAccountInfo (
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
decodeTokenVerbose(ctx, token)
const { account: caller, extra } = decodeTokenVerbose(ctx, token)
if (accountId !== caller) {
const isAdmin = extra?.admin === 'true'
const isAllowedService = verifyAllowedServices(['workspace', 'tool'], extra, false)
if (!isAdmin && !isAllowedService) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}
const account = await getAccount(db, accountId)
if (account === undefined || account === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))