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
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.
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.
runner_lifecycle::create_session_compat trusts the body's agent.id and creates a session.
distributed_task::next_message calls take_matching_job, marks the job in progress, injects a fresh runtime token, and returns the job message.
- 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.
Severity
P0 / critical security
Review baseline
0bd0e314696e520aa47620cbe8f008d010e01e57Summary
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_idPOST /runner/server/_apis/v1/Agent/:pool_id/:agent_idPOST /runner/server/_apis/v1/AgentSession/:pool_idGET /runner/server/_apis/v1/Message/:pool_idrunner/serveraliasesThese routes are not protected by the strict runner-registration gate. The global
resolve_runner_identitymiddleware is non-rejecting and only annotates a request when a credential happens to verify.Source path
runner_lifecycle::register_runner_compataccepts caller-controlled name, labels, group and public key, creates a runner, and returns an OAuthclientId.x-preloop-provision-tokenis optional and only affects pool pairing.oauth::oauth2_tokenaccepts the JSON compatibility shape when the suppliedclient_idexists;client_secretis ignored and the response is a locally signedRunnerListenJWT.runner_lifecycle::create_session_compattrusts the body'sagent.idand creates a session.distributed_task::next_messagecallstake_matching_job, marks the job in progress, injects a fresh runtime token, and returns the job message./_apis/..., so untrusted workflow code can reach this compatibility surface through the in-guest control bridge.Source-derived attack flow
Impact
Recommended remediation
RunnerIdentityfor session creation and polling, and bindagent.id, session id, and message operations to it.SocketSurface.Acceptance criteria
clientId.