Files
multica/server/internal/handler/actor_guards.go
Bohan JiangandCatCatUncle 3ac53a68a6 MUL-7108 fix(autopilot): judge every autopilot write as the human it acts for (#8107)
Autopilot write endpoints judged the request's authenticated user, which under
a mat_ task token is the RUNTIME OWNER. Any member could therefore @ an agent
running on someone else's machine and spend that machine owner's autopilot
rights — pause a rule, re-point a webhook, rotate a token, or grant themselves
a collaborator row.

Every write on this surface now resolves the human it acts FOR (a member acts
for themselves, an agent for its run's originator), through one seam, and
stamps that same human into created_by / published_by / granted_by and the
rule versions. The gate and the row can no longer name two different people.

Two bypasses of that gate are closed with it:

- The webhook credential was broadcast to the whole workspace on
  autopilot:updated, handing every member the token GetAutopilot had just
  redacted for them. All five trigger fan-outs now carry a redacted copy; the
  HTTP caller still gets the live value.
- Agent identity itself was forgeable: Auth stripped only X-Actor-Source, so a
  member could replay an observable (agent_id, task_id) pair — both returned by
  GET /api/issues/{id}/task-runs — and be resolved as that agent. Auth and
  DaemonAuth now strip both headers, leaving the mat_ branch as the only writer
  of agent identity.

That last part is MUL-3428, reported and fixed by @CatCatUncle in #4313; it is
carried here because this change cannot be correct on top of a forgeable actor.

Behavior tightening, no migration: the only path it breaks is a member changing
an autopilot they hold no grant on. A run with no originator can no longer
write here.

Co-authored-by: CatCatUncle <[email protected]>
2026-09-07 16:46:07 +08:00

121 lines
5.5 KiB
Go

package handler
import (
"net/http"
)
// RequireHumanActor is a chi-style middleware that rejects requests
// authenticated via a machine credential — currently mat_ task tokens
// and mcn_ cloud-node PATs. It exists for endpoints whose
// authorization model is "the human owner authorized this", not
// "anyone holding the owner's credentials authorized this".
//
// Why this guard is needed (read carefully — auth here is subtle):
//
// The general Auth middleware (server/internal/middleware/auth.go)
// turns four different bearer formats into the same shape — a stamped
// `X-User-ID` header — so downstream handlers don't have to care which
// token kind the caller used:
//
// - JWT cookie / mul_ PAT → X-User-ID = the human's user id.
// X-Actor-Source is left empty.
// - mat_ task token → X-User-ID = the OWNING human's user id,
// plus X-Agent-ID, X-Task-ID, and the
// authoritative server-set header
// `X-Actor-Source: task_token`.
// - mcn_ cloud-node PAT → X-User-ID = the OWNING human's user id,
// plus `X-Actor-Source: cloud_pat`.
// The token authenticates a cloud-runtime
// EC2 node operating on the owner's
// behalf — same conceptual category as
// mat_ (machine running owner-scoped
// code) for authorization purposes.
//
// The mat_ and mcn_ designs (MUL-2600 and the cloud-node PAT story
// respectively) were both deliberately built this way: every request
// the agent / node makes is treated as the owner's, so they can
// post comments, claim issues, register runtimes, etc., as if the
// owner had done it. That is correct for issue / comment / chat
// scopes — those are bounded by workspace membership and by the
// task or runtime binding.
//
// It is NOT correct for account-level scopes:
//
// - Billing balance / transactions / batches / topups list
// are user-scoped. A running agent or a cloud node could read
// its owner's wallet state without the owner ever having
// approved a billing query.
//
// - Checkout / portal session creation can move money. A machine
// credential that gets compromised — by a prompt injection, a
// bad MCP tool, an escaped quote in scratch data, or a node
// escape — could spin up a checkout for an attacker-controlled
// email or open a Billing Portal session that leaks subscription
// / payment-method state.
//
// `X-Actor-Source` is server-set only. The Auth middleware deletes any
// client-supplied value first (see auth.go: `r.Header.Del("X-Actor-Source")`),
// then re-sets it ONLY on the mat_ and mcn_ branches. So checking
// this header is the safe, fast, single-source-of-truth way to know
// "is the request from a machine credential?" — without re-querying
// the token table.
//
// We deliberately do NOT use h.resolveActor() here:
//
// - resolveActor's primary job is "agent vs member" classification
// for ownership / authorship attribution (issue creator, comment
// author, etc.). It also has a fallback path that reads
// X-Agent-ID + X-Task-ID directly; since MUL-3428 both middlewares
// strip those headers, so that path is unreachable from the network
// and irrelevant here either way. Billing authorization needs the
// strict "machine credential → forbidden" gate, nothing else.
// - resolveActor takes a workspaceID parameter; billing routes have
// no workspace context, so threading one through just to call it
// would be misleading.
// - resolveActor doesn't currently classify mcn_ cloud-node PATs
// because cloud nodes don't act on workspace-scoped resources
// where author attribution matters. Bolting that classification
// into resolveActor solely to reuse it here would be the wrong
// coupling.
//
// Apply via `r.Use(handler.RequireHumanActor)` on a chi route group.
// The middleware is intentionally NOT wired in via the router's main
// Auth chain — the default contract elsewhere (issues, chat, etc.) is
// "agent and human are interchangeable", and adding a global gate
// would break legitimate agent traffic. Only attach it where the
// scope is truly human-only.
//
// To extend: any new machine-credential auth branch added to
// auth.go (e.g. a hypothetical service-account token) MUST stamp a
// distinct X-Actor-Source value AND get reviewed against this gate
// at the same time. The denylist below is intentionally explicit —
// silently passing an unknown actor source is a feature, not a bug
// (see TestRequireHumanActor_IgnoresUnknownActorSource), but the
// addition of a new value is the moment to decide whether it's
// human-equivalent or machine-equivalent.
func RequireHumanActor(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isMachineCredentialActor(r) {
writeError(w, http.StatusForbidden, "this endpoint is only available to human actors")
return
}
next.ServeHTTP(w, r)
})
}
// isMachineCredentialActor centralizes the authoritative actor-source check so
// sensitive handlers can keep a fail-closed backstop in addition to their
// router middleware. Unknown actor sources intentionally remain human-equivalent
// until their authentication branch is explicitly classified here.
func isMachineCredentialActor(r *http.Request) bool {
// X-Actor-Source is server-set only. The auth middleware strips any
// client-supplied value before stamping its own, so a recognized value here
// is authoritative.
switch r.Header.Get("X-Actor-Source") {
case "task_token", "cloud_pat":
return true
default:
return false
}
}