Files
yushen 86d309dbb8 fix(billing): block mat_ task-token actors from /api/cloud-billing/*
Adds handler.RequireHumanActor — a chi middleware that 403s any
request carrying X-Actor-Source=task_token — and applies it to the
cloud-billing route group.

Why this matters (the bug it fixes):

The general Auth middleware (server/internal/middleware/auth.go)
turns every recognized bearer format into the same shape: a stamped
X-User-ID header. JWT cookie, mul_ PAT, AND mat_ task token all
produce a non-empty X-User-ID. That uniformity is correct for issue
/ comment / chat scopes — agents acting as their owner inside a
bounded (agent, task) job is exactly what mat_ is designed for.

It is NOT correct for account-level scopes:

  * Reading balance / transactions / batches / topups list lets an
    agent see its owner's wallet state without the owner approving
    the query.

  * Creating checkout / portal sessions can move money or leak
    payment-method state. A compromised agent (prompt-injected, bad
    MCP tool, escaped quote in scratch data) could spin up a checkout
    bound to an attacker-controlled email or open a Billing Portal
    session.

Before this commit, /api/cloud-billing/* sat outside the workspace
group with only the generic Auth chain, so an mat_ task token's
stamped X-User-ID was indistinguishable from an mul_ PAT's. The
proxy happily forwarded everything to multica-cloud, where the
upstream's owner-only contract was bypassed entirely.

The fix:

  * RequireHumanActor middleware checks the SERVER-SET (not client-
    settable) X-Actor-Source header. The Auth middleware deletes any
    client-supplied value before stamping its own, so a non-empty
    'task_token' value here is authoritative — a member-token caller
    cannot forge it, and an mat_-token caller cannot strip it.

  * Wired in via r.Use(handler.RequireHumanActor) on the
    /api/cloud-billing route group. Applying it as middleware (not
    inline in each handler) means a developer adding a new billing
    endpoint cannot accidentally skip the gate.

  * Deliberately denylist-shaped, not allowlist. Future actor kinds
    added to auth.go (e.g. service-account tokens) get a conscious
    decision-point at the gate rather than being pre-emptively shut
    out by an over-broad rule today.

The Stripe webhook is untouched — it sits outside the entire Auth
group, so X-Actor-Source is never stamped on it (the auth middleware
doesn't run). The guard is irrelevant on that path.

Tests:

  * TestRequireHumanActor_AllowsHumanRequest — empty actor source
    passes through (JWT / mul_ PAT path).
  * TestRequireHumanActor_BlocksTaskToken — 'task_token' value
    triggers 403 and inner handler is not called.
  * TestRequireHumanActor_IgnoresUnknownActorSource — pins the
    denylist shape with explicit comment so the next person adding
    an actor kind knows where to revisit.
  * TestRequireHumanActor_AppliedViaChiRouterUse — small but
    important: builds a real chi router with r.Use(...) and proves
    the guard fires on every endpoint in the group, which is the
    contract router.go relies on.

go build / go vet / go test ./... clean.
2026-05-28 15:40:28 +08:00
..