Add ability to fallback to chgrp remote_tmp and its files. (#68627)

* Add ability to fallback to chgrp remote_tmp and its files.

Signed-off-by: Rick Elrod <rick@elrod.me>
This commit is contained in:
Rick Elrod 2020-07-01 14:16:56 -05:00 committed by GitHub
parent b9e38e8b55
commit 91aea92c62
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 470 additions and 29 deletions

View file

@ -72,7 +72,7 @@ Become connection variables
You can define different ``become`` options for each managed node or group. You can define these variables in inventory or use them as normal variables.
ansible_become
equivalent of the become directive, decides if privilege escalation is used or not.
overrides the ``become`` directive, decides if privilege escalation is used or not.
ansible_become_method
which privilege escalation method should be used
@ -83,6 +83,9 @@ ansible_become_user
ansible_become_password
set the privilege escalation password. See :ref:`playbooks_vault` for details on how to avoid having secrets in plain text
ansible_common_remote_group
determines if Ansible should try to ``chgrp`` its temporary files to a group if ``setfacl`` and ``chown`` both fail. See `Risks of becoming an unprivileged user`_ for more information. Added in version 2.10.
For example, if you want to run all tasks as ``root`` on a server named ``webserver``, but you can only connect as the ``manager`` user, you could use an inventory entry like this:
.. code-block:: text
@ -125,20 +128,57 @@ and finally executing it there.
Everything is fine if the module file is executed without using ``become``,
when the ``become_user`` is root, or when the connection to the remote machine
is made as root. In these cases Ansible creates the module file with permissions
that only allow reading by the user and root, or only allow reading by the unprivileged
user being switched to.
is made as root. In these cases Ansible creates the module file with
permissions that only allow reading by the user and root, or only allow reading
by the unprivileged user being switched to.
However, when both the connection user and the ``become_user`` are unprivileged,
the module file is written as the user that Ansible connects as, but the file needs to
be readable by the user Ansible is set to ``become``. In this case, Ansible makes
the module file world-readable for the duration of the Ansible module execution.
the module file is written as the user that Ansible connects as (the
``remote_user``), but the file needs to be readable by the user Ansible is set
to ``become``. The details of how Ansible solves this can vary based on platform.
However, on POSIX systems, Ansible solves this problem in the following way:
First, if :command:`setfacl` is installed and available in the remote ``PATH``,
and the temporary directory on the remote host is mounted with POSIX.1e
filesystem ACL support, Ansible will use POSIX ACLs to share the module file
with the second unprivileged user.
Next, if POSIX ACLs are **not** available or :command:`setfacl` could not be
run, Ansible will attempt to change ownership of the module file using
:command:`chown` for systems which support doing so as an unprivileged user.
New in Ansible 2.10, if the :command:`chown` fails, Ansible will then check the
value of the configuration setting ``ansible_common_remote_group``. Many
systems will allow a given user to change the group ownership of a file to a
group the user is in. As a result, if the second unprivileged user (the
``become_user``) has a UNIX group in common with the user Ansible is connected
as (the ``remote_user``), and if ``ansible_common_remote_group`` is defined to
be that group, Ansible can try to change the group ownership of the module file
to that group by using :command:`chgrp`, thereby likely making it readable to
the ``become_user``.
At this point, if ``ansible_common_remote_group`` was defined and a
:command:`chgrp` was attempted and returned successfully, Ansible assumes (but,
importantly, does not check) that the new group ownership is enough and does not
fall back further. That is, Ansible **does not check** that the ``become_user``
does in fact share a group with the ``remote_user``; so long as the command
exits successfully, Ansible considers the result successful and does not proceed
to check ``allow_world_readable_tmpfiles`` per below.
If ``ansible_common_remote_group`` is **not** set and the chown above it failed,
or if ``ansible_common_remote_group`` *is* set but the :command:`chgrp` (or
following group-permissions :command:`chmod`) returned a non-successful exit
code, Ansible will lastly check the value of
``allow_world_readable_tmpfiles``. If this is set, Ansible will place the module
file in a world-readable temporary directory, with world-readable permissions to
allow the ``become_user`` (and incidentally any other user on the system) to
read the contents of the file. **If any of the parameters passed to the module
are sensitive in nature, and you do not trust the remote machines, then this is
a potential security risk.**
Once the module is done executing, Ansible deletes the temporary file.
If any of the parameters passed to the module are sensitive in nature, and you do
not trust the client machines, then this is a potential danger.
Ways to resolve this include:
Several ways exist to avoid the above logic flow entirely:
* Use `pipelining`. When pipelining is enabled, Ansible does not save the
module to a temporary file on the client. Instead it pipes the module to
@ -146,12 +186,6 @@ Ways to resolve this include:
python modules involving file transfer (for example: :ref:`copy <copy_module>`,
:ref:`fetch <fetch_module>`, :ref:`template <template_module>`), or for non-python modules.
* Install POSIX.1e filesystem acl support on the
managed host. If the temporary directory on the remote host is mounted with
POSIX acls enabled and the :command:`setfacl` tool is in the remote ``PATH``
then Ansible will use POSIX acls to share the module file with the second
unprivileged user instead of having to make the file readable by everyone.
* Avoid becoming an unprivileged
user. Temporary files are protected by UNIX file permissions when you
``become`` root or do not use ``become``. In Ansible 2.1 and above, UNIX
@ -167,14 +201,32 @@ Ways to resolve this include:
Ansible makes it hard to unknowingly use ``become`` insecurely. Starting in Ansible 2.1,
Ansible defaults to issuing an error if it cannot execute securely with ``become``.
If you cannot use pipelining or POSIX ACLs, you must connect as an unprivileged user,
you must use ``become`` to execute as a different unprivileged user,
and you decide that your managed nodes are secure enough for the
If you cannot use pipelining or POSIX ACLs, must connect as an unprivileged user,
must use ``become`` to execute as a different unprivileged user,
and decide that your managed nodes are secure enough for the
modules you want to run there to be world readable, you can turn on
``allow_world_readable_tmpfiles`` in the :file:`ansible.cfg` file. Setting
``allow_world_readable_tmpfiles`` will change this from an error into
a warning and allow the task to run as it did prior to 2.1.
.. versionchanged:: 2.10
Ansible 2.10 introduces the above-mentioned ``ansible_common_remote_group``
fallback. As mentioned above, if enabled, it is used when ``remote_user`` and
``become_user`` are both unprivileged users. Refer to the text above for details
on when this fallback happens.
.. warning:: As mentioned above, if ``ansible_common_remote_group`` and
``allow_world_readable_tmpfiles`` are both enabled, it is unlikely that the
world-readable fallback will ever trigger, and yet Ansible might still be
unable to access the module file. This is because after the group ownership
change is successful, Ansible does not fall back any further, and also does
not do any check to ensure that the ``become_user`` is actually a member of
the "common group". This is a design decision made by the fact that doing
such a check would require another round-trip connection to the remote
machine, which is a time-expensive operation. Ansible does, however, emit a
warning in this case.
Not supported by all connection plugins
---------------------------------------

View file

@ -510,11 +510,24 @@ class ActionBase(with_metaclass(ABCMeta, object)):
file with chown which only works in case the remote_user is
privileged or the remote systems allows chown calls by unprivileged
users (e.g. HP-UX)
* If the chown fails we can set the file to be world readable so that
* If the chown fails, we check if ansible_common_remote_group is set.
If it is, we attempt to chgrp the file to its value. This is useful
if the remote_user has a group in common with the become_user. As the
remote_user, we can chgrp the file to that group and allow the
become_user to read it.
* If (the chown fails AND ansible_common_remote_group is not set) OR
(ansible_common_remote_group is set AND the chgrp (or following chmod)
returned non-zero), we can set the file to be world readable so that
the second unprivileged user can read the file.
Since this could allow other users to get access to private
information we only do this if ansible is configured with
"allow_world_readable_tmpfiles" in the ansible.cfg
"allow_world_readable_tmpfiles" in the ansible.cfg. Also note that
when ansible_common_remote_group is set this final fallback is very
unlikely to ever be triggered, so long as chgrp was successful. But
just because the chgrp was successful, does not mean Ansible can
necessarily access the files (if, for example, the variable was set
to a group that remote_user is in, and can chgrp to, but does not have
in common with become_user).
"""
if remote_user is None:
remote_user = self._get_remote_user()
@ -528,6 +541,8 @@ class ActionBase(with_metaclass(ABCMeta, object)):
# Unprivileged user that's different than the ssh user. Let's get
# to work!
become_user = self.get_become_option('become_user')
# Try to use file system acls to make the files readable for sudo'd
# user
if execute:
@ -540,7 +555,7 @@ class ActionBase(with_metaclass(ABCMeta, object)):
# start to we'll have to fix this.
setfacl_mode = 'r-X'
res = self._remote_set_user_facl(remote_paths, self.get_become_option('become_user'), setfacl_mode)
res = self._remote_set_user_facl(remote_paths, become_user, setfacl_mode)
if res['rc'] != 0:
# File system acls failed; let's try to use chown next
# Set executable bit first as on some systems an
@ -550,12 +565,48 @@ class ActionBase(with_metaclass(ABCMeta, object)):
if res['rc'] != 0:
raise AnsibleError('Failed to set file mode on remote temporary files (rc: {0}, err: {1})'.format(res['rc'], to_native(res['stderr'])))
res = self._remote_chown(remote_paths, self.get_become_option('become_user'))
if res['rc'] != 0 and remote_user in self._get_admin_users():
# chown failed even if remote_user is administrator/root
raise AnsibleError('Failed to change ownership of the temporary files Ansible needs to create despite connecting as a privileged user. '
'Unprivileged become user would be unable to read the file.')
elif res['rc'] != 0:
res = self._remote_chown(remote_paths, become_user)
if res['rc'] != 0:
# First check if we are an admin/root user. If we are
# and failed here, something weird has happened.
if remote_user in self._get_admin_users():
# chown failed even if remote_user is administrator/root
raise AnsibleError('Failed to change ownership of the temporary files Ansible needs to create despite connecting as a privileged user. '
'Unprivileged become user would be unable to read the file.')
# Otherwise, we're a normal user. We failed to chown the
# paths to the unprivileged user, but if we have a common
# group with them, we should be able to chown it to that.
#
# Note that we have no way of knowing if this will actually
# work... just because chgrp exits successfully does not
# mean that Ansible will work. We could check if the become
# user is in the group, but this would create an extra
# round trip.
#
# Also note that due to the above, this can prevent the
# ALLOW_WORLD_READABLE_TMPFILES logic below from ever
# getting called. We leave this up to the user to rectify
# if they have both of these features enabled.
group = self.get_shell_option('common_remote_group')
if group is not None:
res = self._remote_chgrp(remote_paths, group)
if res['rc'] == 0:
# If ALLOW_WORLD_READABLE_TMPFILES is set, we should warn the user
# that something might go weirdly here.
if C.ALLOW_WORLD_READABLE_TMPFILES:
display.warning('Both common_remote_group and allow_world_readable_tmpfiles are set. chgrp was successful, but there is no '
'guarantee that Ansible will be able to read the files after this operation, particularly if '
'common_remote_group was set to a group of which the unprivileged become user is not a member. In this '
'situation, allow_world_readable_tmpfiles is a no-op. See the "Risks of becoming an unprivileged user" section '
'of the "Understanding privilege escalation: become" user guide documentation for more information')
if execute:
group_mode = 'g+rwx'
else:
group_mode = 'g+rw'
res = self._remote_chmod(remote_paths, group_mode)
if res['rc'] != 0:
if self.get_shell_option('world_readable_temp', C.ALLOW_WORLD_READABLE_TMPFILES):
# chown and fs acls failed -- do things this insecure
# way only if the user opted in in the config file
@ -595,6 +646,14 @@ class ActionBase(with_metaclass(ABCMeta, object)):
res = self._low_level_execute_command(cmd, sudoable=sudoable)
return res
def _remote_chgrp(self, paths, group, sudoable=False):
'''
Issue a remote chgrp command
'''
cmd = self._connection._shell.chgrp(paths, group)
res = self._low_level_execute_command(cmd, sudoable=sudoable)
return res
def _remote_set_user_facl(self, paths, user, mode, sudoable=False):
'''
Issue a remote call to setfacl

View file

@ -19,6 +19,19 @@ options:
key: remote_tmp
vars:
- name: ansible_remote_tmp
common_remote_group:
name: Enables changing the group ownership of temporary files and directories
default: null
description:
- Checked when Ansible needs to execute a module as a different user.
- If setfacl and chown both fail and do not let the different user access the module's files, they will be chgrp'd to this group.
- In order for this to work, the remote_user and become_user must share a common group and this setting must be set to that group.
env: [{name: ANSIBLE_COMMON_REMOTE_GROUP}]
vars:
- name: ansible_common_remote_group
ini:
- {key: common_remote_group, section: defaults}
version_added: "2.10"
system_tmpdirs:
description:
- "List of valid system temporary directories for Ansible to choose when it cannot use

View file

@ -107,6 +107,13 @@ class ShellBase(AnsiblePlugin):
return ' '.join(cmd)
def chgrp(self, paths, group):
cmd = ['chgrp', group]
cmd.extend(paths)
cmd = [shlex_quote(c) for c in cmd]
return ' '.join(cmd)
def set_user_facl(self, paths, user, mode):
"""Only sets acls for users as that's really all we need"""
cmd = ['setfacl', '-m', 'u:%s:%s' % (user, mode)]

View file

@ -0,0 +1,4 @@
destructive
shippable/posix/group1
skip/aix
needs/ssh

View file

@ -0,0 +1,8 @@
- name: Clean up host
hosts: ssh
gather_facts: yes
# Default, just noted here to be explicit about what is happening:
remote_user: root
roles:
- cleanup_become_unprivileged

View file

@ -0,0 +1,7 @@
[ssh]
ssh-pipelining ansible_ssh_pipelining=true
#ssh-no-pipelining ansible_ssh_pipelining=false
[ssh:vars]
ansible_host=localhost
ansible_connection=ssh
ansible_python_interpreter="{{ ansible_playbook_python }}"

View file

@ -0,0 +1 @@
hello I was copied

View file

@ -0,0 +1,58 @@
- name: Run a command
shell: whoami
register: whoami
# TODO: We ignore_errors here because atomic_move has some really weird edge
# cases and gives different behavior based on whether the tmpdir we are copying
# from is on the same partition as the target or not, among other things. There
# is probably work to be done there to either unify the behavior if possible, or
# if not, document/add a warning.
#
# In what follows, unpriv1 is remote_user and unpriv2 is become_user. Both
# users are unprivileged.
#
# In particular, given a system (FreeBSD in my testing, but probably any *nix)
# with a single partition, when we connect (as unpriv1) and become unpriv2,
# the file ends up being unpriv1:commongroup. We can't chown it after that
# since we are become_user, so the file remains owned by unpriv1.
#
# But when we have multiple partitions, os.rename() in atomic_move fails, and
# we end up falling back to a whole new bunch of logic. In the end the file
# ends up being creted as unpriv2 and is unpriv2:unpriv2_login_group.
#
# This creates a bunch of inconsistency and really should be documented better
# but the relevant part for *this* test is that in the single-partition case,
# we cannot chmod in the `if creating` branch of atomic_move since we do not
# own the file. That will generate an error.
- name: Copy a file
copy:
src: baz.txt
dest: ~/uh-oh
owner: unpriv2
group: notcoolenoughforroot
mode: 0644
ignore_errors: yes
- name: See if the file exists
stat:
path: ~/uh-oh
register: uh_oh_stat
#- name: Get files in /var/tmp
# find:
# paths: "/var/tmp/"
# patterns: 'ansible*'
# file_type: directory
# register: found
#
#- name: Get latest ansible tmp dir
# set_fact:
# tmpdir: "{{ found.files | sort(attribute='mtime') | last }}"
#
#- debug: var=tmpdir
- assert:
that:
- whoami.stdout == 'unpriv2'
- uh_oh_stat.stat.exists
#- tmpdir.gr_name == 'notcoolenoughforroot'

View file

@ -0,0 +1,74 @@
# Do this first so we can use tilde notation while the user still exists
- name: Delete homedirs
file:
path: '~{{ item }}'
state: absent
with_items:
- unpriv1
- unpriv2
- name: Delete users
user:
name: "{{ item }}"
state: absent
force: yes # I think this is needed in case pipelining is used and the session remains open
with_items:
- unpriv1
- unpriv2
- name: Delete groups
group:
name: "{{ item }}"
state: absent
with_items:
- notcoolenoughforroot
- unpriv1
- unpriv2
- name: Fix sudoers.d path for FreeBSD
set_fact:
sudoers_etc: /usr/local/etc
when: ansible_distribution == 'FreeBSD'
- name: Fix sudoers.d path for everything else
set_fact:
sudoers_etc: /etc
when: ansible_distribution != 'FreeBSD'
- name: Undo OpenSUSE
lineinfile:
path: "{{ sudoers_etc }}/sudoers"
regexp: '^### Defaults targetpw'
line: 'Defaults targetpw'
backrefs: yes
- name: Nuke custom sudoers file
file:
path: "{{ sudoers_etc }}/sudoers.d/unpriv1"
state: absent
- name: Check if /usr/bin/setfacl exists
stat:
path: /usr/bin/setfacl
register: usr_bin_setfacl
- name: Check if the /bin/setfacl exists
stat:
path: /bin/setfacl
register: bin_setfacl
- name: Set path to setfacl
set_fact:
setfacl_path: /usr/bin/setfacl
when: usr_bin_setfacl.stat.exists
- name: Set path to setfacl
set_fact:
setfacl_path: /bin/setfacl
when: bin_setfacl.stat.exists
- name: chmod +x setfacl
file:
path: "{{ setfacl_path }}"
mode: +x
when: setfacl_path is defined

View file

@ -0,0 +1,128 @@
---
####################################################################
# NOTE! Any destructive changes you make here... Undo them in
# cleanup_become_unprivileged so that they don't affect other tests.
####################################################################
- name: Create groups for unprivileged users
group:
name: "{{ item }}"
with_items:
- notcoolenoughforroot
- unpriv1
- unpriv2
# MacOS requires unencrypted password
- name: Set password for unpriv1 (MacOSX)
set_fact:
password: 'iWishIWereCoolEnoughForRoot!'
when: ansible_distribution == 'MacOSX'
- name: Set password for unpriv1 (everything else)
set_fact:
password: $6$CRuKRUfAoVwibjUI$1IEOISMFAE/a0VG73K9QsD0uruXNPLNkZ6xWg4Sk3kZIXwv6.YJLECzfNjn6pu8ay6XlVcj2dUvycLetL5Lgx1
when: ansible_distribution != 'MacOSX'
# This user is special. It gets a password so we can sudo as it
# (we set the sudo password in runme.sh) and it gets wheel so it can
# `become` unpriv2 without an overly complex sudoers file.
- name: Create first unprivileged user
user:
name: unpriv1
groups: unpriv1,notcoolenoughforroot
append: yes
password: "{{ password }}"
- name: Create second unprivileged user
user:
name: unpriv2
groups: unpriv2,notcoolenoughforroot
append: yes
- name: Create .ssh for unpriv1
file:
path: ~unpriv1/.ssh
state: directory
owner: unpriv1
group: unpriv1
mode: 0700
- name: Set authorized key for unpriv1
copy:
src: ~root/.ssh/authorized_keys
dest: ~unpriv1/.ssh/authorized_keys
remote_src: yes
owner: unpriv1
group: unpriv1
mode: 0600
# Without this we get:
# "Failed to connect to the host via ssh: "System is booting up. Unprivileged
# users are not permitted to log in yet. Please come back later."
- name: Nuke /run/nologin
file:
path: /run/nologin
state: absent
- name: Fix sudoers.d path for FreeBSD
set_fact:
sudoers_etc: /usr/local/etc
when: ansible_distribution == 'FreeBSD'
- name: Fix sudoers.d path for everything else
set_fact:
sudoers_etc: /etc
when: sudoers_etc is not defined
- name: Chown group for bsd and osx
set_fact:
chowngroup: wheel
when: ansible_distribution in ('FreeBSD', 'MacOSX')
- name: Chown group for everything else
set_fact:
chowngroup: root
when: chowngroup is not defined
- name: Make it so unpriv1 can sudo (Chapter 1)
copy:
dest: "{{ sudoers_etc }}/sudoers.d/unpriv1"
content: unpriv1 ALL=(ALL) ALL
owner: root
group: "{{ chowngroup }}"
mode: 0644
# OpenSUSE has a weird sudo default here and requires the root pw
# instead of the user pw. Undo that setting, we can clean it up later.
- name: Make it so unpriv1 can sudo (Chapter 2 - The Return Of the OpenSUSE)
lineinfile:
dest: "{{ sudoers_etc }}/sudoers"
regexp: '^Defaults targetpw'
line: '### Defaults targetpw'
backrefs: yes
- name: Check if /usr/bin/setfacl exists
stat:
path: /usr/bin/setfacl
register: usr_bin_setfacl
- name: Check if the /bin/setfacl exists
stat:
path: /bin/setfacl
register: bin_setfacl
- name: Set path to setfacl
set_fact:
setfacl_path: /usr/bin/setfacl
when: usr_bin_setfacl.stat.exists
- name: Set path to setfacl
set_fact:
setfacl_path: /bin/setfacl
when: bin_setfacl.stat.exists
- name: chmod -x setfacl
file:
path: "{{ setfacl_path }}"
mode: -x
when: setfacl_path is defined

View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -ux
ansible-playbook setup.yml -i inventory -v "$@"
export ANSIBLE_KEEP_REMOTE_FILES=True
export ANSIBLE_COMMON_REMOTE_GROUP=notcoolenoughforroot
export ANSIBLE_BECOME_PASS='iWishIWereCoolEnoughForRoot!'
ansible-playbook test.yml -i inventory -v "$@"
# Do a few cleanup tasks (nuke users, groups, and homedirs, undo config changes)
ansible-playbook cleanup.yml -i inventory -v "$@"

View file

@ -0,0 +1,8 @@
- name: Set up host
hosts: ssh
gather_facts: yes
# Default, just noted here to be explicit about what is happening:
remote_user: root
roles:
- setup_become_unprivileged

View file

@ -0,0 +1,8 @@
- name: Run the test
hosts: ssh
gather_facts: yes
remote_user: unpriv1
become: yes
become_user: unpriv2
roles:
- become_unprivileged