---
title: Improper authorisation in GitLab's runner API: authorise once, act everywhere
subtitle: GitLab's runner update path checks the caller's rights once, at the runner, and does not re-check them on each project the change touches. A short look at the pattern, why it matters, and how we disclosed it.
description: A broken-access-control pattern in GitLab's CI runner API. The runner update and removal path authorises the caller once, at the runner level, rather than per project. We walk through why a single check is not enough for a resource shared across many projects, show the finding in 80 seconds, and set out how it was disclosed.
author: CISO AI
published: 2026-10-26
tags: broken access control, improper authorisation, gitlab, ci cd security, idor, vulnerability disclosure
image: /intel/analysis-gitlab-runner-idor.jpg
imageSize: 1280x720
video: T4xwh9r77oY
videoTitle: A GitLab runner authorization gap, in 80 seconds
---

Most of what we publish is a summary of someone else's reporting. This is the other kind of piece: a finding of our own, in software a great many businesses build on. The 80 seconds above show it as a picture. Below is the detail, and the part that matters for your own systems is at the end.

::: callout info
The finding is an incomplete-fix variant of **CVE-2025-11246**, CVSS 5.4 (medium), reproduced on GitLab 19.5.1. The earlier fix scoped the delete operation but not the authorisation, so the `runnerUpdate` path still detaches a runner from a project the caller has no standing in: in the clip above the runner's `projectCount` drops from 3 to 2, with no error returned.
:::

## How a runner comes to serve many projects

GitLab runs its CI/CD jobs on *runners*. A runner can be registered for a single project, for a group, or for the whole instance, and a single runner is commonly associated with many projects at once. That sharing is the point of a shared runner: you stand up the capacity once, and projects draw on it. Managing that association, and the runner's own settings, happens through the runner API.

So a runner is not a single-owner object. It is one resource reachable from many projects, each with its own membership and its own administrators.

## One check, at the runner level

The behaviour sits in the path that updates a runner and unassigns it from projects. The authorisation check runs once, against the runner itself. It does not then re-check the caller's rights on each project the change affects.

In access-control terms that is *improper authorisation* (CWE-285): the decision is made at the wrong granularity. The code asks "may this caller act on this runner?" and never asks the follow-up it needs to ask: "may this caller act on *each project* this action will touch?"

## Why one check is not enough

For a single-owner object, one check is fine. For a shared one, it is not. Authorising an action once, at the shared object, lets a caller who is entitled to touch the runner reach projects they have no standing in. The administrators of those projects never granted anything; the check that should have represented them was never run.

The correct shape is the opposite, and it is dull on purpose: authorise per resource, every time, on every project the action names. One missing loop is the whole difference.

## What this means in practice

This is a pattern, not a packaged exploit. We are describing where the check sits and why its placement is wrong, because that is the useful lesson. The impact of the class is a cross-boundary action in CI infrastructure, which is exactly the kind of place an attacker wants to stand: runners hold registration tokens, see job payloads, and run code. Authorisation that stops at the runner and never reaches the project is a boundary that looks closed and is not.

## Vulnerability disclosure

This is an incomplete-fix variant of CVE-2025-11246. The earlier fix scoped the delete operation but left the authorisation check sitting at the runner level, so the `runnerUpdate` path still removes a runner from a project without re-checking the caller's rights on that project. We reproduced it on GitLab 19.5.1, reported it through GitLab's coordinated disclosure programme, and published nothing until that process had run. It was triaged as a duplicate of the original issue.

We are writing it up now as an educational example of an authorisation anti-pattern, not as a live weapon to be aimed at anyone. There is no step-by-step exploit in this piece, and nothing here enables one.

## The lesson for your own systems

Wherever a single API call changes several resources, a shared runner across projects, a service account across tenants, a group action across members, ask one question: where does the authorisation check sit? If it runs once, against the shared object, you probably have this pattern. The fix does not need to be clever. Check the caller's rights on each resource the call will touch, every time, and fail the whole call if any one of them says no.

Over to you. When one action in your systems spans several resources, are you confident the permission check runs for each of them, or just for the one the request names? The comments are open below.
