CVE-2026-68968High (CVSS 7.5)Fixed6 min read
Two parsers for one path segment let an Airflow user cancel another team's backfill
Airflow's Backfill API resolved the owning Dag with int() inside the authorization dependency and with pydantic's NonNegativeInt inside the route handler. The two disagree on "1.0", and FastAPI runs dependencies first, so a request could be authorized against a Dag the caller nominated in the query string and then executed against a completely different one. A user with edit permission on a single Dag could read, pause and cancel any other Dag's backfills, failing their queued runs. Fixed in Airflow 3.3.1.
- Vendor
- Apache Airflow
- Product
- Airflow REST API
- Weakness
- CWE-436
- Affected
- Before 3.3.1
- Fixed in
- 3.3.1
- Advisory
- GHSA-j4jc-cq9h-xrhr
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Disclosure timeline
Jul 31, 2026
Reported privately to security@airflow.apache.org, with a reproduction verified end to end against the released apache-airflow 3.2.2 wheel.
Aug 4, 2026
Fix merged as apache/airflow#70889 and backported to the 3.3 branch as #71090.
Aug 12, 2026
CVE-2026-68968 published. Apache rated it Important; NVD scored it 7.5 High. GHSA-j4jc-cq9h-xrhr mirrored the record the same day.
Aug 18, 2026
The CVE record credits the report as finder: Harish Kolla (@Har1sh-k).
A backfill in Airflow runs a Dag over a range of dates that has already passed. Backfills belong to a Dag, and Airflow's per-Dag permission model is the documented way to scope users to a subset of Dags in a deployment shared by several teams. Under the FAB auth manager there is no Backfill resource at all, so every backfill route authorizes through the owning Dag's DAG Run:<dag_id> resource instead. Getting from a backfill id to the Dag that owns it is therefore the whole authorization decision.
The Backfill API did that lookup twice, in two places, with two different parsers.
The bug
In airflow/api_fastapi/core_api/security.py, requires_access_backfill reads the backfill_id path segment and parses it with int(). If that raises, it does not reject the request. It sets the id to None and carries on:
backfill_id_raw = request.path_params.get("backfill_id")
try:
backfill_id = int(backfill_id_raw) if backfill_id_raw is not None else None
except ValueError:
backfill_id = None # fail open
if backfill_id is not None:
backfill = session.scalars(...).one_or_none()
dag_id = backfill.dag_id if backfill else None
With backfill_id set to None the Backfill row is never loaded, dag_id stays None, and the request falls through to requires_access_dag(method, DagAccessEntity.RUN, None). That function, given no explicit Dag, falls back to reading one off the request:
dag_id = request.path_params.get("dag_id") or request.query_params.get("dag_id")
The backfill routes have no dag_id path component. So the authorization decision is made against a dag_id the caller typed into the query string.
On its own that is only half of it, because a request whose id genuinely fails to parse should still die at the handler. The handlers declare the same segment as pydantic's NonNegativeInt, and pydantic's lax mode accepts strings int() rejects. int("1.0") raises ValueError; pydantic coerces "1.0" to 1. FastAPI resolves route dependencies before it validates the endpoint's own path parameters, so on a request to /api/v2/backfills/1.0 the dependency saw a string it could not parse while the handler received the integer 1.
Two components, one path segment, two different answers about which backfill this request is about. The dependency authorized against attacker_dag because the caller said so; the handler then acted on backfill 1, which belongs to victim_dag.
Only the decimal-point spellings work. Measured against the pinned pydantic 2.13.4, "1.0" and "1.00" are accepted by pydantic and rejected by int(). "1.", "1e0", "1.5" and "0x1" are rejected by pydantic too, and answer 422.
What it looks like
Two Dags, victim_dag and attacker_dag. Backfill 1 belongs to the victim, backfill 2 to the attacker. The reporting user holds can_read and can_edit on DAG Run:attacker_dag and DAG:attacker_dag, and nothing else:
GET /api/v2/backfills/2 200 own backfill, sanity check
GET /api/v2/backfills/1 403 Forbidden
GET /api/v2/backfills/1?dag_id=attacker_dag 403 Forbidden, query param alone is not enough
GET /api/v2/backfills/1.0?dag_id=attacker_dag 200 {"id":1,"dag_id":"victim_dag", ...}
The third line matters as much as the fourth. The query parameter by itself changes nothing; it is only load bearing once the unparseable id has knocked out the real lookup.
The same differential reaches the state changing routes, and this is where it stops being a disclosure:
PUT /api/v2/backfills/1/pause?dag_id=attacker_dag 403 Forbidden
PUT /api/v2/backfills/1.0/pause?dag_id=attacker_dag 200
PUT /api/v2/backfills/1.0/cancel?dag_id=attacker_dag 200
cancel_backfill pauses the target, then moves its queued DagRuns to failed and stamps completed_at. The metadata DB afterwards:
id | dag_id | is_paused | completed_at
1 | victim_dag | 1 | 2026-07-31 23:44:13.440359
2 | attacker_dag | 0 |
Backfill ids are sequential integers, so there is no discovery problem.
The part that does not show up in the logs
Airflow writes an audit row for each of these calls, and builds it from params = {**request.query_params, **request.path_params}, persisting dag_id=params.get("dag_id"). Since the backfill routes carry no dag_id path parameter, the value stored is again the one the caller chose. The cancellation that failed victim_dag's runs was recorded as:
event=cancel_backfill owner=attacker Log.dag_id='attacker_dag'
extra={"backfill_id": "1.0", "method": "PUT"}
PermittedEventLogFilter scopes audit log reads by dag_id, so the owner of victim_dag, reading their own audit log, does not see this event at all. It is filed under the Dag the caller nominated. The destructive action was unauthorized and, from the affected team's side, invisible. Fixing the authorization check resolves this too, because the mismatched dag_id can no longer be attacker chosen.
The fix
apache/airflow#70889 makes the dependency parse the id with the exact type the routes declare, using a shared TypeAdapter so the two cannot drift again:
_BACKFILL_ID_ADAPTER: TypeAdapter[NonNegativeInt] = TypeAdapter(NonNegativeInt)
try:
# Must parse exactly as the handler does (e.g. pydantic's lax mode coerces "1.0" to 1
# where int() raises), or the two can authorize and act on different backfills.
backfill_id = (
_BACKFILL_ID_ADAPTER.validate_python(backfill_id_raw) if backfill_id_raw is not None else None
)
except ValidationError:
backfill_id = None
Falling back to None on ValidationError is now safe rather than fail open, because anything the adapter rejects the endpoint's own parser also rejects, so FastAPI answers 422 before the handler runs. The PR adds a test that pins the property directly: for "42", "42.0" and "42.00", the dependency must resolve the same backfill the handler will act on. The change shipped in Airflow 3.3.1.
Worth noting what the report did not claim. A sweep of all fourteen requires_access_dag routes that lack a dag_id path parameter came back clean, so this was one route rather than a general pattern, and the report said so.
On the severity
NVD scored this PR:N, which reads as no privileges required. The advisory text is more accurate: the caller has to be an authenticated user holding edit permission on at least one Dag. That is a low bar in a multi-team deployment, since FAB creates the per-Dag resources automatically for every Dag and this is the ordinary way teams are scoped, but it is not nothing, and the published 7.5 is generous on that axis. The impact side is understated in the other direction: the vector records confidentiality only, while the cancel path is a destructive write against another team's Dag runs.
If you run Airflow
Upgrade to 3.3.1. The exposure is real for any deployment that uses per-Dag permissions to separate teams, which is the configuration the feature exists for. If you keep audit logs for incident work, note that backfill events written by an affected version carry the caller's chosen dag_id, not necessarily the Dag that was modified.
The durable lesson is narrower than "validate your input." Both parsers here were reasonable in isolation. The defect was that an authorization check and the code it guards were allowed to disagree about what the request meant, and the framework's resolution order guaranteed the check ran on the raw value while the handler ran on the coerced one. Any time a guard re-parses something the handler will parse again, the two parsers are part of the security boundary, and the only durable fix is to make them literally the same parser.