Only print warning when ansible.cfg is actually skipped
* Also add unittests for the find_ini_config_file function
* Add documentation on world writable current working directory
config files can no longer be loaded from a world writable current
working directory but the end user is allowed to specify that
explicitly. Give appropriate warnings and information on how.
Fixes #42388
(cherry picked from commit 30662bedadda1cc00efb1946e8f75c5b9fb42d66)
Co-authored-by: Toshio Kuratomi <a.badger@gmail.com>
1 | 1 |
new file mode 100644 |
... | ... |
@@ -0,0 +1,9 @@ |
0 |
+--- |
|
1 |
+bugfixes: |
|
2 |
+- The fix for `CVE-2018-10875 <https://access.redhat.com/security/cve/cve-2018-10875>`_ |
|
3 |
+ prints out a warning message about skipping a config file from a world |
|
4 |
+ writable current working directory. However, if the user explicitly |
|
5 |
+ specifies that the config file should be used via the ANSIBLE_CONFIG |
|
6 |
+ environment variable then Ansible would honor that but still print out the |
|
7 |
+ warning message. This has been fixed so that Ansible honors the user's |
|
8 |
+ explicit wishes and does not print a warning message in that circumstance. |
... | ... |
@@ -39,6 +39,40 @@ Ansible will process the above list and use the first file found, all others are |
39 | 39 |
inventory = /etc/ansible/hosts ; This points to the file that lists your hosts |
40 | 40 |
|
41 | 41 |
|
42 |
+.. _cfg_in_world_writable_dir: |
|
43 |
+ |
|
44 |
+Avoiding security risks with ``ansible.cfg`` in the current directory |
|
45 |
+--------------------------------------------------------------------- |
|
46 |
+ |
|
47 |
+ |
|
48 |
+If Ansible were to load :file:ansible.cfg from a world-writable current working |
|
49 |
+directory, it would create a serious security risk. Another user could place |
|
50 |
+their own config file there, designed to make Ansible run malicious code both |
|
51 |
+locally and remotely, possibly with elevated privileges. For this reason, |
|
52 |
+Ansible will not automatically load a config file from the current working |
|
53 |
+directory if the directory is world-writable. |
|
54 |
+ |
|
55 |
+If you depend on using Ansible with a config file in the current working |
|
56 |
+directory, the best way to avoid this problem is to restrict access to your |
|
57 |
+Ansible directories to particular user(s) and/or group(s). If your Ansible |
|
58 |
+directories live on a filesystem which has to emulate Unix permissions, like |
|
59 |
+Vagrant or Windows Subsystem for Linux (WSL), you may, at first, not know how |
|
60 |
+you can fix this as ``chmod``, ``chown``, and ``chgrp`` might not work there. |
|
61 |
+In most of those cases, the correct fix is to modify the mount options of the |
|
62 |
+filesystem so the files and directories are readable and writable by the users |
|
63 |
+and groups running Ansible but closed to others. For more details on the |
|
64 |
+correct settings, see: |
|
65 |
+ |
|
66 |
+* for Vagrant, Jeremy Kendall's `blog post <http://jeremykendall.net/2013/08/09/vagrant-synced-folders-permissions/>`_ covers synced folder permissions. |
|
67 |
+* for WSL, the `WSL docs <https://docs.microsoft.com/en-us/windows/wsl/wsl-config#set-wsl-launch-settings>`_ |
|
68 |
+ and this `Microsoft blog post <https://blogs.msdn.microsoft.com/commandline/2018/01/12/chmod-chown-wsl-improvements/>`_ cover mount options. |
|
69 |
+ |
|
70 |
+If you absolutely depend on having the config live in a world-writable current |
|
71 |
+working directory, you can explicitly specify the config file via the |
|
72 |
+:envvar:`ANSIBLE_CONFIG` environment variable. Please take |
|
73 |
+appropriate steps to mitigate the security concerns above before doing so. |
|
74 |
+ |
|
75 |
+ |
|
42 | 76 |
Common Options |
43 | 77 |
============== |
44 | 78 |
|
... | ... |
@@ -5,6 +5,7 @@ from __future__ import (absolute_import, division, print_function) |
5 | 5 |
__metaclass__ = type |
6 | 6 |
|
7 | 7 |
import os |
8 |
+import os.path |
|
8 | 9 |
import sys |
9 | 10 |
import stat |
10 | 11 |
import tempfile |
... | ... |
@@ -144,31 +145,59 @@ def find_ini_config_file(warnings=None): |
144 | 144 |
''' Load INI Config File order(first found is used): ENV, CWD, HOME, /etc/ansible ''' |
145 | 145 |
# FIXME: eventually deprecate ini configs |
146 | 146 |
|
147 |
- path0 = os.getenv("ANSIBLE_CONFIG", None) |
|
148 |
- if path0 is not None: |
|
149 |
- path0 = unfrackpath(path0, follow=False) |
|
150 |
- if os.path.isdir(path0): |
|
151 |
- path0 += "/ansible.cfg" |
|
147 |
+ if warnings is None: |
|
148 |
+ # Note: In this case, warnings does nothing |
|
149 |
+ warnings = set() |
|
150 |
+ |
|
151 |
+ # A value that can never be a valid path so that we can tell if ANSIBLE_CONFIG was set later |
|
152 |
+ # We can't use None because we could set path to None. |
|
153 |
+ SENTINEL = object |
|
154 |
+ |
|
155 |
+ potential_paths = [] |
|
156 |
+ |
|
157 |
+ # Environment setting |
|
158 |
+ path_from_env = os.getenv("ANSIBLE_CONFIG", SENTINEL) |
|
159 |
+ if path_from_env is not SENTINEL: |
|
160 |
+ path_from_env = unfrackpath(path_from_env, follow=False) |
|
161 |
+ if os.path.isdir(path_from_env): |
|
162 |
+ path_from_env = os.path.join(path_from_env, "ansible.cfg") |
|
163 |
+ potential_paths.append(path_from_env) |
|
164 |
+ |
|
165 |
+ # Current working directory |
|
166 |
+ warn_cmd_public = False |
|
152 | 167 |
try: |
153 |
- path1 = os.getcwd() |
|
154 |
- perms1 = os.stat(path1) |
|
155 |
- if perms1.st_mode & stat.S_IWOTH: |
|
156 |
- if warnings is not None: |
|
157 |
- warnings.add("Ansible is in a world writable directory (%s), ignoring it as an ansible.cfg source." % to_text(path1)) |
|
158 |
- path1 = None |
|
168 |
+ cwd = os.getcwd() |
|
169 |
+ perms = os.stat(cwd) |
|
170 |
+ if perms.st_mode & stat.S_IWOTH: |
|
171 |
+ warn_cmd_public = True |
|
159 | 172 |
else: |
160 |
- path1 += "/ansible.cfg" |
|
173 |
+ potential_paths.append(os.path.join(cwd, "ansible.cfg")) |
|
161 | 174 |
except OSError: |
162 |
- path1 = None |
|
163 |
- path2 = unfrackpath("~/.ansible.cfg", follow=False) |
|
164 |
- path3 = "/etc/ansible/ansible.cfg" |
|
175 |
+ # If we can't access cwd, we'll simply skip it as a possible config source |
|
176 |
+ pass |
|
177 |
+ |
|
178 |
+ # Per user location |
|
179 |
+ potential_paths.append(unfrackpath("~/.ansible.cfg", follow=False)) |
|
165 | 180 |
|
166 |
- for path in [path0, path1, path2, path3]: |
|
167 |
- if path is not None and os.path.exists(path): |
|
181 |
+ # System location |
|
182 |
+ potential_paths.append("/etc/ansible/ansible.cfg") |
|
183 |
+ |
|
184 |
+ for path in potential_paths: |
|
185 |
+ if os.path.exists(path): |
|
168 | 186 |
break |
169 | 187 |
else: |
170 | 188 |
path = None |
171 | 189 |
|
190 |
+ # Emit a warning if all the following are true: |
|
191 |
+ # * We did not use a config from ANSIBLE_CONFIG |
|
192 |
+ # * There's an ansible.cfg in the current working directory that we skipped |
|
193 |
+ if path_from_env != path and warn_cmd_public: |
|
194 |
+ warnings.add(u"Ansible is being run in a world writable directory (%s)," |
|
195 |
+ u" ignoring it as an ansible.cfg source." |
|
196 |
+ u" For more information see" |
|
197 |
+ u" https://docs.ansible.com/ansible/devel/reference_appendices/config.html#cfg-in-world-writable-dir" |
|
198 |
+ % to_text(cwd)) |
|
199 |
+ |
|
172 | 200 |
return path |
173 | 201 |
|
174 | 202 |
|
176 | 204 |
new file mode 100644 |
... | ... |
@@ -0,0 +1,221 @@ |
0 |
+# -*- coding: utf-8 -*- |
|
1 |
+# Copyright: (c) 2017, Ansible Project |
|
2 |
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) |
|
3 |
+ |
|
4 |
+# Make coding more python3-ish |
|
5 |
+from __future__ import (absolute_import, division) |
|
6 |
+__metaclass__ = type |
|
7 |
+ |
|
8 |
+import os |
|
9 |
+import os.path |
|
10 |
+import stat |
|
11 |
+ |
|
12 |
+import pytest |
|
13 |
+ |
|
14 |
+from ansible.config.manager import find_ini_config_file |
|
15 |
+ |
|
16 |
+ |
|
17 |
+real_exists = os.path.exists |
|
18 |
+real_isdir = os.path.isdir |
|
19 |
+ |
|
20 |
+working_dir = os.path.dirname(__file__) |
|
21 |
+cfg_in_cwd = os.path.join(working_dir, 'ansible.cfg') |
|
22 |
+ |
|
23 |
+cfg_dir = os.path.join(working_dir, 'data') |
|
24 |
+cfg_file = os.path.join(cfg_dir, 'ansible.cfg') |
|
25 |
+alt_cfg_file = os.path.join(cfg_dir, 'test.cfg') |
|
26 |
+cfg_in_homedir = os.path.expanduser('~/.ansible.cfg') |
|
27 |
+ |
|
28 |
+ |
|
29 |
+@pytest.fixture |
|
30 |
+def setup_env(request): |
|
31 |
+ cur_config = os.environ.get('ANSIBLE_CONFIG', None) |
|
32 |
+ cfg_path = request.param[0] |
|
33 |
+ |
|
34 |
+ if cfg_path is None and cur_config: |
|
35 |
+ del os.environ['ANSIBLE_CONFIG'] |
|
36 |
+ else: |
|
37 |
+ os.environ['ANSIBLE_CONFIG'] = request.param[0] |
|
38 |
+ |
|
39 |
+ yield |
|
40 |
+ |
|
41 |
+ if cur_config is None and cfg_path: |
|
42 |
+ del os.environ['ANSIBLE_CONFIG'] |
|
43 |
+ else: |
|
44 |
+ os.environ['ANSIBLE_CONFIG'] = cur_config |
|
45 |
+ |
|
46 |
+ |
|
47 |
+@pytest.fixture |
|
48 |
+def setup_existing_files(request, monkeypatch): |
|
49 |
+ def _os_path_exists(path): |
|
50 |
+ if path in (request.param[0]): |
|
51 |
+ return True |
|
52 |
+ else: |
|
53 |
+ return False |
|
54 |
+ |
|
55 |
+ # Enable user and system dirs so that we know cwd takes precedence |
|
56 |
+ monkeypatch.setattr("os.path.exists", _os_path_exists) |
|
57 |
+ monkeypatch.setattr("os.getcwd", lambda: os.path.dirname(cfg_dir)) |
|
58 |
+ monkeypatch.setattr("os.path.isdir", lambda path: True if path == cfg_dir else real_isdir(path)) |
|
59 |
+ |
|
60 |
+ |
|
61 |
+class TestFindIniFile: |
|
62 |
+ # This tells us to run twice, once with a file specified and once with a directory |
|
63 |
+ @pytest.mark.parametrize('setup_env, expected', (([alt_cfg_file], alt_cfg_file), ([cfg_dir], cfg_file)), indirect=['setup_env']) |
|
64 |
+ # This just passes the list of files that exist to the fixture |
|
65 |
+ @pytest.mark.parametrize('setup_existing_files', |
|
66 |
+ [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, alt_cfg_file, cfg_file)]], |
|
67 |
+ indirect=['setup_existing_files']) |
|
68 |
+ def test_env_has_cfg_file(self, setup_env, setup_existing_files, expected): |
|
69 |
+ """ANSIBLE_CONFIG is specified, use it""" |
|
70 |
+ warnings = set() |
|
71 |
+ assert find_ini_config_file(warnings) == expected |
|
72 |
+ assert warnings == set() |
|
73 |
+ |
|
74 |
+ @pytest.mark.parametrize('setup_env', ([alt_cfg_file], [cfg_dir]), indirect=['setup_env']) |
|
75 |
+ @pytest.mark.parametrize('setup_existing_files', |
|
76 |
+ [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd)]], |
|
77 |
+ indirect=['setup_existing_files']) |
|
78 |
+ def test_env_has_no_cfg_file(self, setup_env, setup_existing_files): |
|
79 |
+ """ANSIBLE_CONFIG is specified but the file does not exist""" |
|
80 |
+ |
|
81 |
+ warnings = set() |
|
82 |
+ # since the cfg file specified by ANSIBLE_CONFIG doesn't exist, the one at cwd that does |
|
83 |
+ # exist should be returned |
|
84 |
+ assert find_ini_config_file(warnings) == cfg_in_cwd |
|
85 |
+ assert warnings == set() |
|
86 |
+ |
|
87 |
+ # ANSIBLE_CONFIG not specified |
|
88 |
+ @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) |
|
89 |
+ # All config files are present |
|
90 |
+ @pytest.mark.parametrize('setup_existing_files', |
|
91 |
+ [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, cfg_file, alt_cfg_file)]], |
|
92 |
+ indirect=['setup_existing_files']) |
|
93 |
+ def test_ini_in_cwd(self, setup_env, setup_existing_files): |
|
94 |
+ """ANSIBLE_CONFIG not specified. Use the cwd cfg""" |
|
95 |
+ warnings = set() |
|
96 |
+ assert find_ini_config_file(warnings) == cfg_in_cwd |
|
97 |
+ assert warnings == set() |
|
98 |
+ |
|
99 |
+ # ANSIBLE_CONFIG not specified |
|
100 |
+ @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) |
|
101 |
+ # No config in cwd |
|
102 |
+ @pytest.mark.parametrize('setup_existing_files', |
|
103 |
+ [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_file, alt_cfg_file)]], |
|
104 |
+ indirect=['setup_existing_files']) |
|
105 |
+ def test_ini_in_homedir(self, setup_env, setup_existing_files): |
|
106 |
+ """First config found is in the homedir""" |
|
107 |
+ warnings = set() |
|
108 |
+ assert find_ini_config_file(warnings) == cfg_in_homedir |
|
109 |
+ assert warnings == set() |
|
110 |
+ |
|
111 |
+ # ANSIBLE_CONFIG not specified |
|
112 |
+ @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) |
|
113 |
+ # No config in cwd |
|
114 |
+ @pytest.mark.parametrize('setup_existing_files', [[('/etc/ansible/ansible.cfg', cfg_file, alt_cfg_file)]], indirect=['setup_existing_files']) |
|
115 |
+ def test_ini_in_systemdir(self, setup_env, setup_existing_files): |
|
116 |
+ """First config found is the system config""" |
|
117 |
+ warnings = set() |
|
118 |
+ assert find_ini_config_file(warnings) == '/etc/ansible/ansible.cfg' |
|
119 |
+ assert warnings == set() |
|
120 |
+ |
|
121 |
+ # ANSIBLE_CONFIG not specified |
|
122 |
+ @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) |
|
123 |
+ # No config in cwd |
|
124 |
+ @pytest.mark.parametrize('setup_existing_files', |
|
125 |
+ [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_file, alt_cfg_file)]], |
|
126 |
+ indirect=['setup_existing_files']) |
|
127 |
+ def test_cwd_does_not_exist(self, setup_env, setup_existing_files, monkeypatch): |
|
128 |
+ """Smoketest current working directory doesn't exist""" |
|
129 |
+ def _os_stat(path): |
|
130 |
+ raise OSError('%s does not exist' % path) |
|
131 |
+ monkeypatch.setattr('os.stat', _os_stat) |
|
132 |
+ |
|
133 |
+ warnings = set() |
|
134 |
+ assert find_ini_config_file(warnings) == cfg_in_homedir |
|
135 |
+ assert warnings == set() |
|
136 |
+ |
|
137 |
+ @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) |
|
138 |
+ # No config in cwd |
|
139 |
+ @pytest.mark.parametrize('setup_existing_files', [[list()]], indirect=['setup_existing_files']) |
|
140 |
+ def test_no_config(self, setup_env, setup_existing_files): |
|
141 |
+ """No config present, no config found""" |
|
142 |
+ warnings = set() |
|
143 |
+ assert find_ini_config_file(warnings) is None |
|
144 |
+ assert warnings == set() |
|
145 |
+ |
|
146 |
+ # ANSIBLE_CONFIG not specified |
|
147 |
+ @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) |
|
148 |
+ # All config files are present |
|
149 |
+ @pytest.mark.parametrize('setup_existing_files', |
|
150 |
+ [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, cfg_file, alt_cfg_file)]], |
|
151 |
+ indirect=['setup_existing_files']) |
|
152 |
+ def test_cwd_warning_on_writable(self, setup_env, setup_existing_files, monkeypatch): |
|
153 |
+ """If the cwd is writable, warn and skip it """ |
|
154 |
+ real_stat = os.stat |
|
155 |
+ |
|
156 |
+ def _os_stat(path): |
|
157 |
+ if path == working_dir: |
|
158 |
+ from posix import stat_result |
|
159 |
+ stat_info = list(real_stat(path)) |
|
160 |
+ stat_info[stat.ST_MODE] |= stat.S_IWOTH |
|
161 |
+ return stat_result(stat_info) |
|
162 |
+ else: |
|
163 |
+ return real_stat(path) |
|
164 |
+ |
|
165 |
+ monkeypatch.setattr('os.stat', _os_stat) |
|
166 |
+ |
|
167 |
+ warnings = set() |
|
168 |
+ assert find_ini_config_file(warnings) == cfg_in_homedir |
|
169 |
+ assert len(warnings) == 1 |
|
170 |
+ warning = warnings.pop() |
|
171 |
+ assert u'Ansible is being run in a world writable directory' in warning |
|
172 |
+ assert u'ignoring it as an ansible.cfg source' in warning |
|
173 |
+ |
|
174 |
+ # ANSIBLE_CONFIG is sepcified |
|
175 |
+ @pytest.mark.parametrize('setup_env, expected', (([alt_cfg_file], alt_cfg_file), ([cfg_in_cwd], cfg_in_cwd)), indirect=['setup_env']) |
|
176 |
+ # All config files are present |
|
177 |
+ @pytest.mark.parametrize('setup_existing_files', |
|
178 |
+ [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, cfg_file, alt_cfg_file)]], |
|
179 |
+ indirect=['setup_existing_files']) |
|
180 |
+ def test_no_warning_on_writable_if_env_used(self, setup_env, setup_existing_files, monkeypatch, expected): |
|
181 |
+ """If the cwd is writable but ANSIBLE_CONFIG was used, no warning should be issued""" |
|
182 |
+ real_stat = os.stat |
|
183 |
+ |
|
184 |
+ def _os_stat(path): |
|
185 |
+ if path == working_dir: |
|
186 |
+ from posix import stat_result |
|
187 |
+ stat_info = list(real_stat(path)) |
|
188 |
+ stat_info[stat.ST_MODE] |= stat.S_IWOTH |
|
189 |
+ return stat_result(stat_info) |
|
190 |
+ else: |
|
191 |
+ return real_stat(path) |
|
192 |
+ |
|
193 |
+ monkeypatch.setattr('os.stat', _os_stat) |
|
194 |
+ |
|
195 |
+ warnings = set() |
|
196 |
+ assert find_ini_config_file(warnings) == expected |
|
197 |
+ assert warnings == set() |
|
198 |
+ |
|
199 |
+ # ANSIBLE_CONFIG not specified |
|
200 |
+ @pytest.mark.parametrize('setup_env', [[None]], indirect=['setup_env']) |
|
201 |
+ # All config files are present |
|
202 |
+ @pytest.mark.parametrize('setup_existing_files', |
|
203 |
+ [[('/etc/ansible/ansible.cfg', cfg_in_homedir, cfg_in_cwd, cfg_file, alt_cfg_file)]], |
|
204 |
+ indirect=['setup_existing_files']) |
|
205 |
+ def test_cwd_warning_on_writable_no_warning_set(self, setup_env, setup_existing_files, monkeypatch): |
|
206 |
+ """Smoketest that the function succeeds even though no warning set was passed in""" |
|
207 |
+ real_stat = os.stat |
|
208 |
+ |
|
209 |
+ def _os_stat(path): |
|
210 |
+ if path == working_dir: |
|
211 |
+ from posix import stat_result |
|
212 |
+ stat_info = list(real_stat(path)) |
|
213 |
+ stat_info[stat.ST_MODE] |= stat.S_IWOTH |
|
214 |
+ return stat_result(stat_info) |
|
215 |
+ else: |
|
216 |
+ return real_stat(path) |
|
217 |
+ |
|
218 |
+ monkeypatch.setattr('os.stat', _os_stat) |
|
219 |
+ |
|
220 |
+ assert find_ini_config_file() == cfg_in_homedir |
... | ... |
@@ -3,6 +3,7 @@ from __future__ import (absolute_import, division, print_function) |
3 | 3 |
__metaclass__ = type |
4 | 4 |
|
5 | 5 |
import os |
6 |
+import os.path |
|
6 | 7 |
|
7 | 8 |
from ansible.compat.tests import unittest |
8 | 9 |
|
... | ... |
@@ -50,12 +51,6 @@ class TestConfigData(unittest.TestCase): |
50 | 50 |
self.assertIsInstance(ensure_type('0.10', 'float'), float) |
51 | 51 |
self.assertIsInstance(ensure_type(0.2, 'float'), float) |
52 | 52 |
|
53 |
- def test_find_ini_file(self): |
|
54 |
- cur_config = os.environ['ANSIBLE_CONFIG'] |
|
55 |
- os.environ['ANSIBLE_CONFIG'] = cfg_file |
|
56 |
- self.assertEquals(cfg_file, find_ini_config_file()) |
|
57 |
- os.environ['ANSIBLE_CONFIG'] = cur_config |
|
58 |
- |
|
59 | 53 |
def test_resolve_path(self): |
60 | 54 |
self.assertEquals(os.path.join(curdir, 'test.yml'), resolve_path('./test.yml', cfg_file)) |
61 | 55 |
|