CVSS 9.9 in GitLab's AI Gateway: When the Prompt Sandbox Is the Attack Surface
💡 Tool Tip:Regex Tester, JSON Formatter, Hash Generator, File Hash Checker
GitLab disclosed a critical flaw in its self-hosted AI Gateway in early October 2026, tracked as CVE-2026-90970, and GitLab as CNA scored it 9.9 out of 10 on CVSS 3.1. The description matters more than the number: an authenticated user with Duo Agent Platform access could escape the prompt template sandbox using a specially crafted flow configuration, ending in arbitrary command execution on the AI Gateway itself. Affected ranges are all versions from 18.1.6 before 19.2.4, 19.3 before 19.3.2, and 19.4 before 19.4.1.
A sandbox is only a boundary if it is tested as one
The access chain, stated plainly
Read the CVE description as a sequence, because a sequence tells you what to do about it. An authenticated user needs Duo Agent Platform permission. They then submit a specially crafted flow configuration. The prompt template engine fails to neutralise special elements, so the crafted input escapes the template sandbox. Once outside, it reaches the underlying operating system and achieves arbitrary command execution on the AI Gateway host. Nothing in that chain is exotic. It is a template injection with a permission prerequisite, and it ended up with the second-highest score the severity scale allows.
# Know what you are running before you read the rest of this.
# Affected ranges, per the CVE record (GitLab Inc. as CNA):
# AI Gateway 18.1.x-19.2.x : 18.1.6 up to (not including) 19.2.4
# 19.3.x : before 19.3.2
# 19.4.x : before 19.4.1
import re
AFFECTED = [(18, 1, 6, 19, 2, 4), (19, 3, 0, 19, 3, 2), (19, 4, 0, 19, 4, 1)]
def parse(v):
m = re.match(r'(\d+)\.(\d+)\.(\d+)', v)
return tuple(int(x) for x in m.groups()) if m else None
def is_affected(version):
t = parse(version)
if t is None:
return None
for lo_a, lo_b, lo_c, hi_a, hi_b, hi_c in AFFECTED:
if (lo_a, lo_b, lo_c) <= t < (hi_a, hi_b, hi_c):
return True
return False
# Enumerate every deployment, including the ones nobody remembers.
print(is_affected('19.2.3')) # True - still in the affected range
CWE-1336 is the whole story
The CWE mapping is CWE-1336, improper neutralization of special elements used in a template engine. This is not a novel category and it is not a machine learning problem. It is the same class of bug that has broken web frameworks for twenty years: a system accepts user input, feeds it to something that evaluates or interpolates, and assumes the input is data when the input is actually code. The detail that makes this instance interesting is the label. This engine exists specifically to sandbox user-authored flows, so the component whose job is to contain untrusted input was itself the thing that failed to contain untrusted input.
# The access chain is the defence plan. Break one link, close the path.
# authenticated user -> Duo Agent Platform permission
# -> crafted flow -> sandbox escape -> command execution
CHAIN = {
'authenticated_user': 'enforce SSO + MFA; no shared bot accounts',
'duo_agent_platform_perm': 'grant to the smallest group that needs it',
'crafted_flow': 'no public self-service flow authoring',
'sandbox_escape': 'patch to a fixed version',
'command_execution': 'least-privilege service account, egress deny',
}
def residual_risk(controls_in_place):
open_links = [k for k in CHAIN if k not in controls_in_place]
return {'open_links': open_links,
'note': 'patching is the only link that fully closes this one'}
# Lateral movement is the part nobody plans for: what can the gateway
# reach? Model credentials, other repos, the database it brokers for.
The gateway is on the path of every AI-assisted review
Why the blast radius is larger than one repository
The AI Gateway is the compute layer underneath GitLab Duo, the AI coding assistant and agent suite. It brokers the prompt flows that feed the language model integrations, and it runs on self-hosted instances. A command-execution foothold there is not a foothold in one repository; every AI-assisted suggestion, review and automated flow passes through that machine. The questions worth asking about your own deployment are the ones about credentials and reach: what model API keys does the gateway hold, what other services can it talk to, how wide is the database access it needs, and what would an attacker do with all three? That last list is where the real damage assessment lives.
# Look for the shape of the exploit in your own logs, not the payload.
# The CWE here is CWE-1336: template engine, special elements not neutralised.
import re, json
SUSPICIOUS = [
re.compile(r'\$\{'), # template interpolation
re.compile(r'\{\{'), # handlebars / jinja style
re.compile(r'<%'), # ERB style
re.compile(r'__\w+__'), # dunder reach-through
re.compile(r'\bimport\b|\brequire\b|\bos\b\.'),
]
def flag_flow(entry):
blob = json.dumps(entry, ensure_ascii=False)
hits = [p.pattern for p in SUSPICIOUS if p.search(blob)]
return {'user': entry.get('user'), 'flow_id': entry.get('flow_id'),
'signals': hits, 'action': 'review' if hits else 'none'}
# This finds attempts, not proofs. It is triage, not a conviction.
The defender's checklist
Patch to a fixed version - 19.2.4, 19.3.2 or 19.4.1 depending on your line - and confirm the version from the running service rather than from a change log. Then reduce the permission prerequisite: who actually needs Duo Agent Platform access, and can that group be smaller? Then constrain what an escaped process could do: least-privilege service account, restricted egress, no long-lived model credentials sitting in the gateway's environment. Then look at the flow authoring surface - if any authenticated user can author a flow, that is a large input surface for a feature that is supposed to be contained.
# The durable fix: never render user-supplied templates with a
# general-purpose engine. Validate against a schema instead.
from string import Template
ALLOWED = {'repo_name', 'branch', 'max_files'} # exhaustive, by design
def render_flow(template: str, params: dict) -> str:
unknown = set(params) - ALLOWED
if unknown:
raise ValueError(f'rejecting flow: unknown params {sorted(unknown)}')
for k, v in params.items():
if not isinstance(v, (str, int)):
raise TypeError(f'{k} must be a scalar')
if isinstance(v, str) and any(c in v for c in '{}<>'):
raise ValueError(f'{k} contains template metacharacters')
return Template(template).safe_substitute(**params)
# safe_substitute + an exhaustive allowlist + no evaluation. If a
string can be evaluated, it will be.
Cut any link in the chain and the path closes
What to log, and what logging cannot do
Log flow authoring events with the user identity, the flow identifier and the full configuration. Look for template metacharacters - the interpolation delimiters, the reach-through double-underscore patterns, the shell invocation verbs - as a triage signal, not as proof. Understand clearly that this is detection after the fact: by the time you see a crafted flow in the logs, it has already been submitted. Detection tells you whether you were targeted and gives you scope; it does not substitute for the boundary. Published advisories for this CVE have not included proof-of-concept exploit code, and it is not worth reconstructing one to test a patch that you can simply apply.
# Prove the patch landed and prove the boundary holds. Both.
#!/usr/bin/env bash
set -euo pipefail
AI_GATEWAY="${AI_GATEWAY:-https://gitlab.example.com}"
TOKEN="${DUO_TOKEN:?set DUO_TOKEN}"
ver=$(curl -sf -H "Authorization: Bearer $TOKEN" \
"$AI_GATEWAY/api/v4/ai_gateway/version" | python3 -c 'import sys,json;print(json.load(sys.stdin)["version"])')
echo "AI Gateway version: $ver"
case "$ver" in
19.2.[4-9]*|19.2.[1-9][0-9]*|19.[3-9].*|2[0-9].*) echo 'version is at or above a fixed line' ;;
*) echo 'STILL AFFECTED - patch before doing anything else'; exit 1 ;;
esac
grep -c 'prompt_template' /var/log/gitlab/ai-gateway/current || true
# Version checks are necessary and they are not sufficient. Check the
# boundary too: can the gateway process spawn a shell at all?
The durable engineering lesson
Never render user-supplied templates with a general-purpose engine. This is not a rule about this CVE or this vendor; it is a rule about text that can be evaluated. If a configuration format needs to be parameterised, give it an exhaustive allowlist of substitutable names, accept only scalars, reject inputs containing template metacharacters, and prefer a substitution call that does not evaluate - the difference between a template and an eval is the difference between a form and a remote code execution primitive. Write that rule down somewhere developers will read it, because the next prompt sandbox will be built by someone who has not heard of this CVE.
📌 Frequently Asked Questions
What is CVE-2026-90970?
A critical vulnerability in the GitLab AI Gateway component, described by its CNA as allowing an authenticated user with Duo Agent Platform access to escape the prompt template sandbox via a specially crafted flow configuration, resulting in arbitrary command execution on the AI Gateway.
How severe is it?
GitLab Inc., acting as CNA, assigned a CVSS 3.1 base score of 9.9 with the vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H. The CWE mapping is CWE-1336, improper neutralization of special elements used in a template engine.
Which versions are affected?
Per the CVE record: all AI Gateway versions from 18.1.6 up to but not including 19.2.4, versions before 19.3.2 in the 19.3 line, and versions before 19.4.1 in the 19.4 line. Confirm the fixed versions on GitLab's advisory for your release line before upgrading.
Does an attacker need credentials?
Yes. The attacker must already be an authenticated user with Duo Agent Platform access. That makes this a privilege-escalation and sandbox-escape issue rather than an anonymous internet-facing one, and it also makes permission hygiene a genuine mitigations step, not a formality.
Can I detect it in logs?
Partially. Flow authoring events should be logged with user identity, flow identifier and full configuration, and template metacharacters in those configurations are worth triaging. But detection here is after the fact: by the time the crafted flow appears in a log, it has been submitted. Treat log analysis as scoping, not as prevention.