Skip to content

Legacy runner compatibility routes allow unauthorized registration and job claiming #154

Description

@Bnjoroge1

Severity

P0 / critical security

Review baseline

0bd0e314696e520aa47620cbe8f008d010e01e57

Summary

The strict registration policy is enforced on /api/v3/actions/runner-registration, but multiple legacy GitHub/Azure DevOps-compatible aliases bypass that policy. A caller that can reach the runner protocol can register an arbitrary runner, obtain a listen JWT, create a session, and poll for matching work.

Affected flows include aliases under:

  • POST /runner/server/_apis/v1/Agent/:pool_id
  • POST /runner/server/_apis/v1/Agent/:pool_id/:agent_id
  • POST /runner/server/_apis/v1/AgentSession/:pool_id
  • GET /runner/server/_apis/v1/Message/:pool_id
  • corresponding org-prefixed and non-runner/server aliases

These routes are not protected by the strict runner-registration gate. The global resolve_runner_identity middleware is non-rejecting and only annotates a request when a credential happens to verify.

Source path

  1. runner_lifecycle::register_runner_compat accepts caller-controlled name, labels, group and public key, creates a runner, and returns an OAuth clientId. x-preloop-provision-token is optional and only affects pool pairing.
  2. oauth::oauth2_token accepts the JSON compatibility shape when the supplied client_id exists; client_secret is ignored and the response is a locally signed RunnerListen JWT.
  3. runner_lifecycle::create_session_compat trusts the body's agent.id and creates a session.
  4. distributed_task::next_message calls take_matching_job, marks the job in progress, injects a fresh runtime token, and returns the job message.
  5. The guest socket permits /_apis/..., so untrusted workflow code can reach this compatibility surface through the in-guest control bridge.

Source-derived attack flow

1. POST /runner/server/_apis/v1/Agent/1
   Register a rogue runner with labels matching a target job.

2. POST /runner/server/_apis/v1/oauth2/token
   Exchange the returned clientId for a RunnerListen JWT.

3. POST /runner/server/_apis/v1/AgentSession/1
   Bind a new session to the rogue runner id.

4. GET /runner/server/_apis/v1/Message/1?sessionId=...
   Poll for and receive a matching job.

Impact

  • Job messages and job-scoped credentials can be delivered to an unauthorized runner.
  • A rogue runner can fabricate completion/reporting data.
  • Arbitrary registrations and sessions can consume memory and durable-store capacity.
  • The embedded ephemeral pool can be disrupted or bypassed.

Recommended remediation

  • Require a server-minted, single-use provision credential for every production registration alias. Bind it to expected runner identity, labels/group, expiry, and preferably the pool/job request.
  • Remove or compile-gate the JSON OAuth mock path in production, or require proof of possession of the registered private key.
  • Require a verified RunnerIdentity for session creation and polling, and bind agent.id, session id, and message operations to it.
  • Default production deployments to assignment-only claiming.
  • Add negative end-to-end tests for every alias over both TCP and SocketSurface.

Acceptance criteria

  • An unauthenticated caller cannot register a runner through any route alias.
  • A caller cannot mint a listen token from only a discovered/created clientId.
  • A runner token cannot create or poll another runner's session.
  • The same tests pass for all GHES/org/AzDO route aliases.
  • Workflow code inside a runner VM cannot use the control socket to create a new runner identity.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions