Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure that unsafe is more difficult to lose [stable-2.14] #82295

Merged
merged 2 commits into from Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelogs/fragments/cve-2023-5764.yml
@@ -0,0 +1,6 @@
security_fixes:
- templating - Address issues where internal templating can cause unsafe
variables to lose their unsafe designation (CVE-2023-5764)
breaking_changes:
- assert - Nested templating may result in an inability for the conditional
to be evaluated. See the porting guide for more information.
4 changes: 2 additions & 2 deletions lib/ansible/module_utils/common/json.py
Expand Up @@ -30,7 +30,7 @@ def _preprocess_unsafe_encode(value):
Used in ``AnsibleJSONEncoder.iterencode``
"""
if _is_unsafe(value):
value = {'__ansible_unsafe': to_text(value, errors='surrogate_or_strict', nonstring='strict')}
value = {'__ansible_unsafe': to_text(value._strip_unsafe(), errors='surrogate_or_strict', nonstring='strict')}
elif is_sequence(value):
value = [_preprocess_unsafe_encode(v) for v in value]
elif isinstance(value, Mapping):
Expand Down Expand Up @@ -63,7 +63,7 @@ def default(self, o):
value = {'__ansible_vault': to_text(o._ciphertext, errors='surrogate_or_strict', nonstring='strict')}
elif getattr(o, '__UNSAFE__', False):
# unsafe object, this will never be triggered, see ``AnsibleJSONEncoder.iterencode``
value = {'__ansible_unsafe': to_text(o, errors='surrogate_or_strict', nonstring='strict')}
value = {'__ansible_unsafe': to_text(o._strip_unsafe(), errors='surrogate_or_strict', nonstring='strict')}
elif isinstance(o, Mapping):
# hostvars and other objects
value = dict(o)
Expand Down
6 changes: 5 additions & 1 deletion lib/ansible/parsing/yaml/dumper.py
Expand Up @@ -24,7 +24,7 @@
from ansible.module_utils.six import text_type, binary_type
from ansible.module_utils.common.yaml import SafeDumper
from ansible.parsing.yaml.objects import AnsibleUnicode, AnsibleSequence, AnsibleMapping, AnsibleVaultEncryptedUnicode
from ansible.utils.unsafe_proxy import AnsibleUnsafeText, AnsibleUnsafeBytes, NativeJinjaUnsafeText, NativeJinjaText
from ansible.utils.unsafe_proxy import AnsibleUnsafeText, AnsibleUnsafeBytes, NativeJinjaUnsafeText, NativeJinjaText, _is_unsafe
from ansible.template import AnsibleUndefined
from ansible.vars.hostvars import HostVars, HostVarsVars
from ansible.vars.manager import VarsWithSources
Expand All @@ -47,10 +47,14 @@ def represent_vault_encrypted_unicode(self, data):


def represent_unicode(self, data):
if _is_unsafe(data):
data = data._strip_unsafe()
return yaml.representer.SafeRepresenter.represent_str(self, text_type(data))


def represent_binary(self, data):
if _is_unsafe(data):
data = data._strip_unsafe()
return yaml.representer.SafeRepresenter.represent_binary(self, binary_type(data))


Expand Down
9 changes: 5 additions & 4 deletions lib/ansible/playbook/conditional.py
Expand Up @@ -26,7 +26,7 @@
from jinja2.exceptions import UndefinedError

from ansible import constants as C
from ansible.errors import AnsibleError, AnsibleUndefinedVariable
from ansible.errors import AnsibleError, AnsibleUndefinedVariable, AnsibleTemplateError
from ansible.module_utils.six import text_type
from ansible.module_utils._text import to_native, to_text
from ansible.playbook.attribute import FieldAttribute
Expand Down Expand Up @@ -138,9 +138,10 @@ def _check_conditional(self, conditional, templar, all_vars):
if not isinstance(conditional, text_type) or conditional == "":
return conditional

# update the lookups flag, as the string returned above may now be unsafe
# and we don't want future templating calls to do unsafe things
disable_lookups |= hasattr(conditional, '__UNSAFE__')
# If the result of the first-pass template render (to resolve inline templates) is marked unsafe,
# explicitly fail since the next templating operation would never evaluate
if hasattr(conditional, '__UNSAFE__'):
raise AnsibleTemplateError('Conditional is marked as unsafe, and cannot be evaluated.')

# First, we do some low-level jinja2 parsing involving the AST format of the
# statement to ensure we don't do anything unsafe (using the disable_lookup flag above)
Expand Down
24 changes: 24 additions & 0 deletions lib/ansible/playbook/task.py
Expand Up @@ -290,6 +290,30 @@ def post_validate(self, templar):

super(Task, self).post_validate(templar)

def _post_validate_args(self, attr, value, templar):
# smuggle an untemplated copy of the task args for actions that need more control over the templating of their
# input (eg, debug's var/msg, assert's "that" conditional expressions)
self.untemplated_args = value

# now recursively template the args dict
args = templar.template(value)

# FIXME: could we just nuke this entirely and/or wrap it up in ModuleArgsParser or something?
if '_variable_params' in args:
variable_params = args.pop('_variable_params')
if isinstance(variable_params, dict):
if C.INJECT_FACTS_AS_VARS:
display.warning("Using a variable for a task's 'args' is unsafe in some situations "
"(see https://docs.ansible.com/ansible/devel/reference_appendices/faq.html#argsplat-unsafe)")
variable_params.update(args)
args = variable_params
else:
# if we didn't get a dict, it means there's garbage remaining after k=v parsing, just give up
# see https://github.com/ansible/ansible/issues/79862
raise AnsibleError(f"invalid or malformed argument: '{variable_params}'")

return args

def _post_validate_loop(self, attr, value, templar):
'''
Override post validation for the loop field, which is templated
Expand Down
23 changes: 22 additions & 1 deletion lib/ansible/plugins/action/assert.py
Expand Up @@ -63,8 +63,29 @@ def run(self, tmp=None, task_vars=None):

quiet = boolean(self._task.args.get('quiet', False), strict=False)

# directly access 'that' via untemplated args from the task so we can intelligently trust embedded
# templates and preserve the original inputs/locations for better messaging on assert failures and
# errors.
# FIXME: even in devel, things like `that: item` don't always work properly (truthy string value
# is not really an embedded expression)
# we could fix that by doing direct var lookups on the inputs
# FIXME: some form of this code should probably be shared between debug, assert, and
# Task.post_validate, since they
# have a lot of overlapping needs
try:
thats = self._task.untemplated_args['that']
except KeyError:
# in the case of "we got our entire args dict from a template", we can just consult the
# post-templated dict (the damage has likely already been done for embedded templates anyway)
thats = self._task.args['that']

# FIXME: this is a case where we only want to resolve indirections, NOT recurse containers
# (and even then, the leaf-most expression being wrapped is at least suboptimal
# (since its expression will be "eaten").
if isinstance(thats, str):
thats = self._templar.template(thats)

# make sure the 'that' items are a list
thats = self._task.args['that']
if not isinstance(thats, list):
thats = [thats]

Expand Down
4 changes: 3 additions & 1 deletion lib/ansible/plugins/callback/__init__.py
Expand Up @@ -38,7 +38,7 @@
from ansible.plugins import AnsiblePlugin
from ansible.utils.color import stringc
from ansible.utils.display import Display
from ansible.utils.unsafe_proxy import AnsibleUnsafeText, NativeJinjaUnsafeText
from ansible.utils.unsafe_proxy import AnsibleUnsafeText, NativeJinjaUnsafeText, _is_unsafe
from ansible.vars.clean import strip_internal_keys, module_response_deepcopy

import yaml
Expand Down Expand Up @@ -113,6 +113,8 @@ def _munge_data_for_lossy_yaml(scalar):

def _pretty_represent_str(self, data):
"""Uses block style for multi-line strings"""
if _is_unsafe(data):
data = data._strip_unsafe()
data = text_type(data)
if _should_use_block(data):
style = '|'
Expand Down
5 changes: 5 additions & 0 deletions lib/ansible/plugins/filter/core.py
Expand Up @@ -37,6 +37,7 @@
from ansible.utils.encrypt import passlib_or_crypt
from ansible.utils.hashing import md5s, checksum_s
from ansible.utils.unicode import unicode_wrap
from ansible.utils.unsafe_proxy import _is_unsafe
from ansible.utils.vars import merge_hash

display = Display()
Expand Down Expand Up @@ -215,6 +216,8 @@ def from_yaml(data):
# The ``text_type`` call here strips any custom
# string wrapper class, so that CSafeLoader can
# read the data
if _is_unsafe(data):
data = data._strip_unsafe()
return yaml_load(text_type(to_text(data, errors='surrogate_or_strict')))
return data

Expand All @@ -224,6 +227,8 @@ def from_yaml_all(data):
# The ``text_type`` call here strips any custom
# string wrapper class, so that CSafeLoader can
# read the data
if _is_unsafe(data):
data = data._strip_unsafe()
return yaml_load_all(text_type(to_text(data, errors='surrogate_or_strict')))
return data

Expand Down
15 changes: 13 additions & 2 deletions lib/ansible/plugins/lookup/first_found.py
Expand Up @@ -136,7 +136,6 @@
elements: path
"""
import os
import re

from collections.abc import Mapping, Sequence

Expand All @@ -147,10 +146,22 @@
from ansible.plugins.lookup import LookupBase


def _splitter(value, chars):
chars = set(chars)
v = ''
for c in value:
if c in chars:
yield v
v = ''
continue
v += c
yield v


def _split_on(terms, spliters=','):
termlist = []
if isinstance(terms, string_types):
termlist = re.split(r'[%s]' % ''.join(map(re.escape, spliters)), terms)
termlist = list(_splitter(terms, spliters))
else:
# added since options will already listify
for t in terms:
Expand Down
15 changes: 13 additions & 2 deletions lib/ansible/template/__init__.py
Expand Up @@ -31,7 +31,7 @@
from numbers import Number
from traceback import format_exc

from jinja2.exceptions import TemplateSyntaxError, UndefinedError
from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError
from jinja2.loaders import FileSystemLoader
from jinja2.nativetypes import NativeEnvironment
from jinja2.runtime import Context, StrictUndefined
Expand All @@ -55,7 +55,7 @@
from ansible.utils.display import Display
from ansible.utils.listify import listify_lookup_plugin_terms
from ansible.utils.native_jinja import NativeJinjaText
from ansible.utils.unsafe_proxy import wrap_var
from ansible.utils.unsafe_proxy import wrap_var, AnsibleUnsafeText, AnsibleUnsafeBytes, NativeJinjaUnsafeText

display = Display()

Expand Down Expand Up @@ -332,10 +332,21 @@ class AnsibleContext(Context):
flag is checked post-templating, and (when set) will result in the
final templated result being wrapped in AnsibleUnsafe.
'''
_disallowed_callables = frozenset({
AnsibleUnsafeText._strip_unsafe.__qualname__,
AnsibleUnsafeBytes._strip_unsafe.__qualname__,
NativeJinjaUnsafeText._strip_unsafe.__qualname__,
})

def __init__(self, *args, **kwargs):
super(AnsibleContext, self).__init__(*args, **kwargs)
self.unsafe = False

def call(self, obj, *args, **kwargs):
if getattr(obj, '__qualname__', None) in self._disallowed_callables or obj in self._disallowed_callables:
raise SecurityError(f"{obj!r} is not safely callable")
return super().call(obj, *args, **kwargs)

def _is_unsafe(self, val):
'''
Our helper function, which will also recursively check dict and
Expand Down