fix(coordinator): bind JWT caller to worker at claim (close quarantine bypass)

The trust tier was read off the caller-supplied worker_id, so a JWT user who
knew any trusted worker's id could claim as it — draining and poisoning the
trusted queue and bypassing the untrusted-worker quarantine entirely.

Claim now requires a JWT caller to own the worker it acts as; a shared-token
caller (lab operator) may still act as any worker. Claim is the sole grantor of
a lease, so this also protects the downstream heartbeat/result/failure paths.

Tests: reject claim as another user's worker; allow claim as own worker.
This commit is contained in:
Efremenko Arhip
2026-07-26 19:26:30 +03:00
parent 80ff72a0fe
commit 163cbe14bf
2 changed files with 46 additions and 0 deletions
+13
View File
@@ -6,6 +6,7 @@ import (
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
)
@@ -51,6 +52,18 @@ func (uc *ClaimTask) Execute(ctx context.Context, in ClaimTaskInput) (*domain.Cl
if err != nil {
return nil, err
}
// Bind the caller to the worker it claims as. A JWT-authenticated
// volunteer may operate only its own workers; without this the trust
// tier would be read off a caller-supplied worker_id, letting anyone who
// knows a trusted worker's id claim as it and bypass the quarantine
// below. A shared-token caller (no requester) is a lab operator and may
// act as any worker, preserving the original behaviour.
if r, ok := authctx.From(ctx); ok {
if worker.OwnerID == nil || *worker.OwnerID != r.UserID {
// Don't disclose that another user's worker exists.
return nil, domain.ErrWorkerNotFound
}
}
// C1 quarantine: an untrusted volunteer worker may register but receives
// no tasks, because there is not yet (until quorum, C2) any way to verify
// its results. Report an empty queue rather than an error, so its poller
@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"github.com/emil28092005/SciMesh/coordinator/internal/authctx"
"github.com/emil28092005/SciMesh/coordinator/internal/domain"
"github.com/emil28092005/SciMesh/coordinator/internal/memstore"
"github.com/emil28092005/SciMesh/coordinator/internal/usecase"
@@ -300,6 +301,38 @@ func TestRegisterWorkerRecordsOwnerAndUntrusted(t *testing.T) {
}
}
func TestJWTCallerCannotClaimAsAnotherUsersWorker(t *testing.T) {
h := newHarness()
h.seedJob(t, "w", 1)
// A trusted lab worker owned by nobody (shared-token registration).
victim, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{Name: "lab", Capabilities: []string{"w"}})
// An attacker authenticated as a JWT user tries to claim as the lab worker.
attacker := authctx.With(ctx, authctx.Requester{UserID: uuid.New(), Role: "user"})
claimed, err := h.claim.Execute(attacker, usecase.ClaimTaskInput{WorkerID: victim.ID.String()})
if !errors.Is(err, domain.ErrWorkerNotFound) {
t.Fatalf("claim as another's worker = (%v, %v), want ErrWorkerNotFound", claimed, err)
}
}
func TestJWTCallerClaimsAsOwnTrustedWorker(t *testing.T) {
h := newHarness()
h.seedJob(t, "w", 1)
owner := uuid.New()
// The user's own worker, trusted (e.g. a verified contributor).
mine, _ := h.register.Execute(ctx, usecase.RegisterWorkerInput{
Name: "mine", Capabilities: []string{"w"}, OwnerID: &owner, TrustLevel: domain.WorkerTrusted,
})
callerCtx := authctx.With(ctx, authctx.Requester{UserID: owner, Role: "user", Verified: true})
got, err := h.claim.Execute(callerCtx, usecase.ClaimTaskInput{WorkerID: mine.ID.String()})
if err != nil || got == nil {
t.Fatalf("own trusted worker claim = (%v, %v), want a task", got, err)
}
}
func TestUntrustedWorkerIsQuarantinedFromClaims(t *testing.T) {
h := newHarness()
h.seedJob(t, "w", 1) // a task is waiting