Skip to content

Commit 2936b80

Browse files
authored
Fix CVE-2024-11079 hostvars unsafe context (#84339)
Fix to preserve an unsafe variable when accessing through an intermediary variable from hostvars.
1 parent 1f88f09 commit 2936b80

File tree

6 files changed

+159
-36
lines changed

6 files changed

+159
-36
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
security_fixes:
2+
- Templating will not prefer AnsibleUnsafe when a variable is referenced via hostvars - CVE-2024-11079

lib/ansible/template/__init__.py

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
from ansible.module_utils.common.text.converters import to_native, to_text, to_bytes
4949
from ansible.module_utils.common.collections import is_sequence
5050
from ansible.plugins.loader import filter_loader, lookup_loader, test_loader
51-
from ansible.template.native_helpers import ansible_native_concat, ansible_eval_concat, ansible_concat
51+
from ansible.template.native_helpers import AnsibleUndefined, ansible_native_concat, ansible_eval_concat, ansible_concat
5252
from ansible.template.template import AnsibleJ2Template
5353
from ansible.template.vars import AnsibleJ2Vars
5454
from ansible.utils.display import Display
@@ -312,35 +312,6 @@ def wrapper(*args, **kwargs):
312312
return functools.update_wrapper(wrapper, func)
313313

314314

315-
class AnsibleUndefined(StrictUndefined):
316-
'''
317-
A custom Undefined class, which returns further Undefined objects on access,
318-
rather than throwing an exception.
319-
'''
320-
def __getattr__(self, name):
321-
if name == '__UNSAFE__':
322-
# AnsibleUndefined should never be assumed to be unsafe
323-
# This prevents ``hasattr(val, '__UNSAFE__')`` from evaluating to ``True``
324-
raise AttributeError(name)
325-
# Return original Undefined object to preserve the first failure context
326-
return self
327-
328-
def __getitem__(self, key):
329-
# Return original Undefined object to preserve the first failure context
330-
return self
331-
332-
def __repr__(self):
333-
return 'AnsibleUndefined(hint={0!r}, obj={1!r}, name={2!r})'.format(
334-
self._undefined_hint,
335-
self._undefined_obj,
336-
self._undefined_name
337-
)
338-
339-
def __contains__(self, item):
340-
# Return original Undefined object to preserve the first failure context
341-
return self
342-
343-
344315
class AnsibleContext(Context):
345316
'''
346317
A custom context, which intercepts resolve_or_missing() calls and sets a flag

lib/ansible/template/native_helpers.py

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@
55

66

77
import ast
8+
from collections.abc import Mapping
89
from itertools import islice, chain
910
from types import GeneratorType
1011

12+
from ansible.module_utils.common.collections import is_sequence
1113
from ansible.module_utils.common.text.converters import to_text
1214
from ansible.module_utils.six import string_types
1315
from ansible.parsing.yaml.objects import AnsibleVaultEncryptedUnicode
1416
from ansible.utils.native_jinja import NativeJinjaText
17+
from ansible.utils.unsafe_proxy import wrap_var
18+
import ansible.module_utils.compat.typing as t
19+
20+
from jinja2.runtime import StrictUndefined
1521

1622

1723
_JSON_MAP = {
@@ -28,6 +34,40 @@ def visit_Name(self, node):
2834
return ast.Constant(value=_JSON_MAP[node.id])
2935

3036

37+
def _is_unsafe(value: t.Any) -> bool:
38+
"""
39+
Our helper function, which will also recursively check dict and
40+
list entries due to the fact that they may be repr'd and contain
41+
a key or value which contains jinja2 syntax and would otherwise
42+
lose the AnsibleUnsafe value.
43+
"""
44+
to_check = [value]
45+
seen = set()
46+
47+
while True:
48+
if not to_check:
49+
break
50+
51+
val = to_check.pop(0)
52+
val_id = id(val)
53+
54+
if val_id in seen:
55+
continue
56+
seen.add(val_id)
57+
58+
if isinstance(val, AnsibleUndefined):
59+
continue
60+
if isinstance(val, Mapping):
61+
to_check.extend(val.keys())
62+
to_check.extend(val.values())
63+
elif is_sequence(val):
64+
to_check.extend(val)
65+
elif getattr(val, '__UNSAFE__', False):
66+
return True
67+
68+
return False
69+
70+
3171
def ansible_eval_concat(nodes):
3272
"""Return a string of concatenated compiled nodes. Throw an undefined error
3373
if any of the nodes is undefined.
@@ -43,17 +83,28 @@ def ansible_eval_concat(nodes):
4383
if not head:
4484
return ''
4585

86+
unsafe = False
87+
4688
if len(head) == 1:
4789
out = head[0]
4890

4991
if isinstance(out, NativeJinjaText):
5092
return out
5193

94+
unsafe = _is_unsafe(out)
5295
out = to_text(out)
5396
else:
5497
if isinstance(nodes, GeneratorType):
5598
nodes = chain(head, nodes)
56-
out = ''.join([to_text(v) for v in nodes])
99+
100+
out_values = []
101+
for v in nodes:
102+
if not unsafe and _is_unsafe(v):
103+
unsafe = True
104+
105+
out_values.append(to_text(v))
106+
107+
out = ''.join(out_values)
57108

58109
# if this looks like a dictionary, list or bool, convert it to such
59110
if out.startswith(('{', '[')) or out in ('True', 'False'):
@@ -68,6 +119,9 @@ def ansible_eval_concat(nodes):
68119
except (TypeError, ValueError, SyntaxError, MemoryError):
69120
pass
70121

122+
if unsafe:
123+
out = wrap_var(out)
124+
71125
return out
72126

73127

@@ -78,7 +132,19 @@ def ansible_concat(nodes):
78132
79133
Used in Templar.template() when jinja2_native=False and convert_data=False.
80134
"""
81-
return ''.join([to_text(v) for v in nodes])
135+
unsafe = False
136+
values = []
137+
for v in nodes:
138+
if not unsafe and _is_unsafe(v):
139+
unsafe = True
140+
141+
values.append(to_text(v))
142+
143+
out = ''.join(values)
144+
if unsafe:
145+
out = wrap_var(out)
146+
147+
return out
82148

83149

84150
def ansible_native_concat(nodes):
@@ -95,6 +161,8 @@ def ansible_native_concat(nodes):
95161
if not head:
96162
return None
97163

164+
unsafe = False
165+
98166
if len(head) == 1:
99167
out = head[0]
100168

@@ -115,10 +183,21 @@ def ansible_native_concat(nodes):
115183
# short-circuit literal_eval for anything other than strings
116184
if not isinstance(out, string_types):
117185
return out
186+
187+
unsafe = _is_unsafe(out)
188+
118189
else:
119190
if isinstance(nodes, GeneratorType):
120191
nodes = chain(head, nodes)
121-
out = ''.join([to_text(v) for v in nodes])
192+
193+
out_values = []
194+
for v in nodes:
195+
if not unsafe and _is_unsafe(v):
196+
unsafe = True
197+
198+
out_values.append(to_text(v))
199+
200+
out = ''.join(out_values)
122201

123202
try:
124203
evaled = ast.literal_eval(
@@ -128,10 +207,45 @@ def ansible_native_concat(nodes):
128207
ast.parse(out, mode='eval')
129208
)
130209
except (TypeError, ValueError, SyntaxError, MemoryError):
210+
if unsafe:
211+
out = wrap_var(out)
212+
131213
return out
132214

133215
if isinstance(evaled, string_types):
134216
quote = out[0]
135-
return f'{quote}{evaled}{quote}'
217+
evaled = f'{quote}{evaled}{quote}'
218+
219+
if unsafe:
220+
evaled = wrap_var(evaled)
136221

137222
return evaled
223+
224+
225+
class AnsibleUndefined(StrictUndefined):
226+
"""
227+
A custom Undefined class, which returns further Undefined objects on access,
228+
rather than throwing an exception.
229+
"""
230+
def __getattr__(self, name):
231+
if name == '__UNSAFE__':
232+
# AnsibleUndefined should never be assumed to be unsafe
233+
# This prevents ``hasattr(val, '__UNSAFE__')`` from evaluating to ``True``
234+
raise AttributeError(name)
235+
# Return original Undefined object to preserve the first failure context
236+
return self
237+
238+
def __getitem__(self, key):
239+
# Return original Undefined object to preserve the first failure context
240+
return self
241+
242+
def __repr__(self):
243+
return 'AnsibleUndefined(hint={0!r}, obj={1!r}, name={2!r})'.format(
244+
self._undefined_hint,
245+
self._undefined_obj,
246+
self._undefined_name
247+
)
248+
249+
def __contains__(self, item):
250+
# Return original Undefined object to preserve the first failure context
251+
return self

lib/ansible/vars/hostvars.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,12 @@ def __contains__(self, host_name):
9292
return self._find_host(host_name) is not None
9393

9494
def __iter__(self):
95-
yield from self._inventory.hosts
95+
# include implicit localhost only if it has variables set
96+
yield from self._inventory.hosts | {'localhost': self._inventory.localhost} if self._inventory.localhost else {}
9697

9798
def __len__(self):
98-
return len(self._inventory.hosts)
99+
# include implicit localhost only if it has variables set
100+
return len(self._inventory.hosts) + (1 if self._inventory.localhost else 0)
99101

100102
def __repr__(self):
101103
out = {}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
- name: test CVE-2024-11079 loop variables preserve unsafe hostvars
2+
hosts: localhost
3+
gather_facts: false
4+
tasks:
5+
- set_fact:
6+
foo:
7+
safe:
8+
prop: '{{ "{{" }} unsafe_var {{ "}}" }}'
9+
unsafe:
10+
prop: !unsafe '{{ unsafe_var }}'
11+
12+
- name: safe var through hostvars loop is templated
13+
assert:
14+
that:
15+
- item.prop == expected
16+
loop:
17+
- "{{ hostvars['localhost']['foo']['safe'] }}"
18+
vars:
19+
unsafe_var: bar
20+
expected: bar
21+
22+
- name: unsafe var through hostvars loop is not templated
23+
assert:
24+
that:
25+
- item.prop == expected
26+
loop:
27+
- "{{ hostvars['localhost']['foo']['unsafe'] }}"
28+
vars:
29+
unsafe_var: bar
30+
expected: !unsafe '{{ unsafe_var }}'

test/integration/targets/template/runme.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ ansible-playbook 72262.yml -v "$@"
4141
# ensure unsafe is preserved, even with extra newlines
4242
ansible-playbook unsafe.yml -v "$@"
4343

44+
# CVE 2024-11079
45+
ANSIBLE_JINJA2_NATIVE=true ansible-playbook cve-2024-11079.yml -v "$@"
46+
ANSIBLE_JINJA2_NATIVE=false ansible-playbook cve-2024-11079.yml -v "$@"
47+
4448
# ensure Jinja2 overrides from a template are used
4549
ansible-playbook template_overrides.yml -v "$@"
4650

0 commit comments

Comments
 (0)