Skip to content

Commit 70e83e7

Browse files
authored
Fix CVE-2024-11079 hostvars unsafe context (#84339) (#84353)
Fix to preserve an unsafe variable when accessing through an intermediary variable from hostvars. (cherry picked from commit 2936b80)
1 parent 12abfb0 commit 70e83e7

File tree

6 files changed

+159
-37
lines changed

6 files changed

+159
-37
lines changed
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

+1-30
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
from ansible.module_utils.common.text.converters import to_native, to_text, to_bytes
5050
from ansible.module_utils.common.collections import is_sequence
5151
from ansible.plugins.loader import filter_loader, lookup_loader, test_loader
52-
from ansible.template.native_helpers import ansible_native_concat, ansible_eval_concat, ansible_concat
52+
from ansible.template.native_helpers import AnsibleUndefined, ansible_native_concat, ansible_eval_concat, ansible_concat
5353
from ansible.template.template import AnsibleJ2Template
5454
from ansible.template.vars import AnsibleJ2Vars
5555
from ansible.utils.display import Display
@@ -329,35 +329,6 @@ def wrapper(*args, **kwargs):
329329
return _update_wrapper(wrapper, func)
330330

331331

332-
class AnsibleUndefined(StrictUndefined):
333-
'''
334-
A custom Undefined class, which returns further Undefined objects on access,
335-
rather than throwing an exception.
336-
'''
337-
def __getattr__(self, name):
338-
if name == '__UNSAFE__':
339-
# AnsibleUndefined should never be assumed to be unsafe
340-
# This prevents ``hasattr(val, '__UNSAFE__')`` from evaluating to ``True``
341-
raise AttributeError(name)
342-
# Return original Undefined object to preserve the first failure context
343-
return self
344-
345-
def __getitem__(self, key):
346-
# Return original Undefined object to preserve the first failure context
347-
return self
348-
349-
def __repr__(self):
350-
return 'AnsibleUndefined(hint={0!r}, obj={1!r}, name={2!r})'.format(
351-
self._undefined_hint,
352-
self._undefined_obj,
353-
self._undefined_name
354-
)
355-
356-
def __contains__(self, item):
357-
# Return original Undefined object to preserve the first failure context
358-
return self
359-
360-
361332
class AnsibleContext(Context):
362333
'''
363334
A custom context, which intercepts resolve_or_missing() calls and sets a flag

lib/ansible/template/native_helpers.py

+118-4
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,19 @@
77

88

99
import ast
10+
from collections.abc import Mapping
1011
from itertools import islice, chain
1112
from types import GeneratorType
1213

14+
from ansible.module_utils.common.collections import is_sequence
1315
from ansible.module_utils.common.text.converters import to_text
1416
from ansible.module_utils.six import string_types
1517
from ansible.parsing.yaml.objects import AnsibleVaultEncryptedUnicode
1618
from ansible.utils.native_jinja import NativeJinjaText
19+
from ansible.utils.unsafe_proxy import wrap_var
20+
import ansible.module_utils.compat.typing as t
21+
22+
from jinja2.runtime import StrictUndefined
1723

1824

1925
_JSON_MAP = {
@@ -30,6 +36,40 @@ def visit_Name(self, node):
3036
return ast.Constant(value=_JSON_MAP[node.id])
3137

3238

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

88+
unsafe = False
89+
4890
if len(head) == 1:
4991
out = head[0]
5092

5193
if isinstance(out, NativeJinjaText):
5294
return out
5395

96+
unsafe = _is_unsafe(out)
5497
out = to_text(out)
5598
else:
5699
if isinstance(nodes, GeneratorType):
57100
nodes = chain(head, nodes)
58-
out = ''.join([to_text(v) for v in nodes])
101+
102+
out_values = []
103+
for v in nodes:
104+
if not unsafe and _is_unsafe(v):
105+
unsafe = True
106+
107+
out_values.append(to_text(v))
108+
109+
out = ''.join(out_values)
59110

60111
# if this looks like a dictionary, list or bool, convert it to such
61112
if out.startswith(('{', '[')) or out in ('True', 'False'):
@@ -70,6 +121,9 @@ def ansible_eval_concat(nodes):
70121
except (TypeError, ValueError, SyntaxError, MemoryError):
71122
pass
72123

124+
if unsafe:
125+
out = wrap_var(out)
126+
73127
return out
74128

75129

@@ -80,7 +134,19 @@ def ansible_concat(nodes):
80134
81135
Used in Templar.template() when jinja2_native=False and convert_data=False.
82136
"""
83-
return ''.join([to_text(v) for v in nodes])
137+
unsafe = False
138+
values = []
139+
for v in nodes:
140+
if not unsafe and _is_unsafe(v):
141+
unsafe = True
142+
143+
values.append(to_text(v))
144+
145+
out = ''.join(values)
146+
if unsafe:
147+
out = wrap_var(out)
148+
149+
return out
84150

85151

86152
def ansible_native_concat(nodes):
@@ -97,6 +163,8 @@ def ansible_native_concat(nodes):
97163
if not head:
98164
return None
99165

166+
unsafe = False
167+
100168
if len(head) == 1:
101169
out = head[0]
102170

@@ -117,10 +185,21 @@ def ansible_native_concat(nodes):
117185
# short-circuit literal_eval for anything other than strings
118186
if not isinstance(out, string_types):
119187
return out
188+
189+
unsafe = _is_unsafe(out)
190+
120191
else:
121192
if isinstance(nodes, GeneratorType):
122193
nodes = chain(head, nodes)
123-
out = ''.join([to_text(v) for v in nodes])
194+
195+
out_values = []
196+
for v in nodes:
197+
if not unsafe and _is_unsafe(v):
198+
unsafe = True
199+
200+
out_values.append(to_text(v))
201+
202+
out = ''.join(out_values)
124203

125204
try:
126205
evaled = ast.literal_eval(
@@ -130,10 +209,45 @@ def ansible_native_concat(nodes):
130209
ast.parse(out, mode='eval')
131210
)
132211
except (TypeError, ValueError, SyntaxError, MemoryError):
212+
if unsafe:
213+
out = wrap_var(out)
214+
133215
return out
134216

135217
if isinstance(evaled, string_types):
136218
quote = out[0]
137-
return f'{quote}{evaled}{quote}'
219+
evaled = f'{quote}{evaled}{quote}'
220+
221+
if unsafe:
222+
evaled = wrap_var(evaled)
138223

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

lib/ansible/vars/hostvars.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,12 @@ def __contains__(self, host_name):
9494
return self._find_host(host_name) is not None
9595

9696
def __iter__(self):
97-
for host in self._inventory.hosts:
98-
yield host
97+
# include implicit localhost only if it has variables set
98+
yield from self._inventory.hosts | {'localhost': self._inventory.localhost} if self._inventory.localhost else {}
9999

100100
def __len__(self):
101-
return len(self._inventory.hosts)
101+
# include implicit localhost only if it has variables set
102+
return len(self._inventory.hosts) + (1 if self._inventory.localhost else 0)
102103

103104
def __repr__(self):
104105
out = {}
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

+4
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)