Skip to content

[Segurança] Guardas de autorização ausentes em funções de core ainda não expostas por rota #1127

Description

@elio-neto

Problema

Três funções de core carecem de guarda de autorização. Nenhuma delas é explorável no commit auditado,
porque nenhuma rota atual passa a elas dado controlado pelo cliente — mas em todas as três a guarda vive
fora da função, no chamador, e a assinatura aceita qualquer valor. São armadilhas esperando a próxima
tela.

Agrupadas em uma issue por serem o mesmo tema: guarda que deveria estar dentro da função e não está.


12a. Workspace.remove não verifica papel de admin

packages/console/core/src/workspace.ts:68-75

export const update = fn(z.object({ name: z.string().min(1).max(255) }), async ({ name }) => {
  Actor.assertAdmin()          // <-- presente em update
  const workspaceID = Actor.workspace()
  ...
})

export const remove = fn(z.void(), async () => {
                               // <-- ausente em remove
  await Database.use((tx) =>
    tx.update(WorkspaceTable).set({ timeDeleted: sql`now()` })
      .where(eq(WorkspaceTable.id, Actor.workspace())),
  )
})

Renomear o workspace exige admin; apagá-lo não. A assimetria esta lado a lado no mesmo arquivo, o que
indica esquecimento. O grep de Workspace.remove não encontra chamador hoje.

Risco: a tela de settings tem espaço natural para um botão de excluir workspace. No dia em que ele
existir, qualquer membro apaga o workspace inteiro.


12b. unsubscribeBlack e unsubscribeLite derivam o inquilino do id recebido

packages/console/core/src/billing.ts:515-538 e 540-563

export const unsubscribeBlack = fn(z.object({ subscriptionID: z.string() }), async ({ subscriptionID }) => {
  const workspaceID = await Database.use((tx) =>
    tx.select({ workspaceID: BillingTable.workspaceID })
      .from(BillingTable)
      .where(eq(BillingTable.subscriptionID, subscriptionID))   // <-- o id do cliente escolhe o inquilino
      .then((rows) => rows[0]?.workspaceID),
  )
  if (!workspaceID) throw new Error("Workspace ID not found for subscription")

  await Database.transaction(async (tx) => {
    await tx.update(BillingTable).set({ subscriptionID: null, subscription: null })
      .where(eq(BillingTable.workspaceID, workspaceID))
    await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
  })
})

Não há Actor.workspace() em nenhuma linha. O único chamador hoje é
packages/console/app/src/routes/stripe/webhook.ts (linhas 186, 188, 197, 199), que valida a assinatura
do evento em constructEventAsync(..., Resource.STRIPE_WEBHOOK_SECRET.value) nas linhas 15-19 — por isso
não há caminho de exploração.

Risco: subscriptionID é devolvido ao frontend por queryBillingInfo
(routes/workspace/common.tsx:114 e :119). Um botão "cancelar assinatura" que chame essas funções com
o id vindo do cliente — o caminho natural — permite a qualquer usuário autenticado cancelar a assinatura
de qualquer workspace.


12c. Key.create aceita userID arbitrário

packages/console/core/src/key.ts:42-73

export const create = fn(
  z.object({ userID: z.string(), name: z.string().min(1).max(255) }),
  async (input) => {
    ...
    await Database.use((tx) =>
      tx.insert(KeyTable).values({
        id: keyID,
        workspaceID: Actor.workspace(),
        userID: input.userID,      // <-- nao validado contra a UserTable nem contra Actor.userID()
        name,
        key: secretKey,
        timeUsed: null,
      }),
    )
    return keyID
  },
)

O workspaceID vem do ator (correto), mas o userID não é conferido. O único chamador de rota,
routes/workspace/[id]/keys/key-section.tsx:32, passa
Actor.assert("user").properties.userID — o próprio ator. Os outros chamadores (workspace.ts:44,
user.ts:120 e :176) rodam sob ator system com userID recém-criado pelo servidor.

Risco: uma tela de administração de chaves que receba o userID do cliente permite a um membro criar
chave atribuída a outro usuário, deslocando a atribuição de custo e de limite mensal
(UserTable.monthlyLimit é consultado por chave em routes/zen/util/handler.ts:601-606) e permitindo
consumir a cota do admin. Como o userID não é validado contra o workspace, uma chave pode apontar para
usuário de outro inquilino.


Sugestão de correção

  • 12a: adicionar Actor.assertAdmin() em Workspace.remove. Considerar exigir confirmação por nome
    e revogar as chaves do workspace na mesma transação.
  • 12b: inverter a responsabilidade — derivar o subscriptionID do workspace do ator em vez do
    contrário. Manter uma variante interna com nome explícito (unsubscribeBlackFromWebhook), comentada
    como de uso restrito ao webhook, e uma variante pública que exige ator de usuário, verifica papel e
    filtra por Actor.workspace().
  • 12c: validar dentro de Key.create que existe UserTable com aquele id, aquele workspaceID e
    timeDeleted nulo; e exigir input.userID === Actor.userID() salvo para ator system ou papel
    admin.

Critérios de aceite

  • Workspace.remove chama Actor.assertAdmin()
  • Teste: ator com papel member recebe erro e WorkspaceTable.timeDeleted permanece nulo
  • Existe variante de unsubscribeBlack/unsubscribeLite para chamada por usuário, que filtra por
    Actor.workspace() e verifica papel
  • A variante que aceita subscriptionID arbitrário tem nome que declara o uso restrito e é chamada
    somente pelo webhook
  • Teste: a variante de usuário recusa subscriptionID de outro workspace
  • Key.create rejeita userID que não pertenca ao workspace do ator
  • Key.create rejeita userID diferente do ator, exceto para ator system ou papel admin
  • Teste: criação de chave com userID de outro usuário falha para papel member

Labels sugeridas: security, media

Achado da auditoria estatica de seguranca do commit 0465a5d. Relatorio completo em docs/security-audit/relatorio-auditoria-seguranca.pdf.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions