mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-13 13:17:45 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"UserProfile": "Benutzerprofil",
|
||||
"DeactivatedAccount": "Deaktivierter Account",
|
||||
"LocalTime": "Ortszeit",
|
||||
"Timezone": "Zeitzone",
|
||||
"Everyone": "Jeder",
|
||||
"Here": "Hier",
|
||||
"EveryoneDescription": "Benachrichtigen Sie alle in diesem {title}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"UserProfile": "ユーザープロフィール",
|
||||
"DeactivatedAccount": "アカウントは無効化されました",
|
||||
"LocalTime": "現地時間",
|
||||
"Timezone": "タイムゾーン",
|
||||
"Everyone": "全員",
|
||||
"Here": "ここ",
|
||||
"EveryoneDescription": "この{title}のすべてに通知する",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"UserProfile": "사용자 프로필",
|
||||
"DeactivatedAccount": "비활성화된 계정",
|
||||
"LocalTime": "현지 시간",
|
||||
"Timezone": "시간대",
|
||||
"Everyone": "모두",
|
||||
"Here": "여기",
|
||||
"EveryoneDescription": "이 {title}의 모든 멤버에게 알림",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
"UserProfile": "Профиль пользователя",
|
||||
"DeactivatedAccount": "Деактивированный аккаунт",
|
||||
"LocalTime": "местного времени",
|
||||
"Timezone": "Часовой пояс",
|
||||
"Everyone": "Все",
|
||||
"Here": "Здесь",
|
||||
"EveryoneDescription": "Уведомить всех в этом {title}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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, {}))
|
||||
|
||||
Reference in New Issue
Block a user