diff --git a/changelogs/fragments/759_cve_2026_11837.yml b/changelogs/fragments/759_cve_2026_11837.yml new file mode 100644 index 0000000..8ae96f1 --- /dev/null +++ b/changelogs/fragments/759_cve_2026_11837.yml @@ -0,0 +1,2 @@ +security_fixes: + - authorized_key - fix local privilege escalation via symlink-following when running as root (https://github.com/ansible-collections/ansible.posix/issues/759). diff --git a/plugins/modules/authorized_key.py b/plugins/modules/authorized_key.py index 17a747c..dc078bc 100644 --- a/plugins/modules/authorized_key.py +++ b/plugins/modules/authorized_key.py @@ -307,6 +307,17 @@ class keydict(dict): return [item[1] for item in self.items()] +def _safe_open_write(module, path, follow): + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if not follow and hasattr(os, 'O_NOFOLLOW'): + flags |= os.O_NOFOLLOW + try: + fd = os.open(path, flags, int('0600', 8)) + except OSError as e: + module.fail_json(msg="File open failed %s : %s" % (path, to_native(e))) + return fd + + def keyfile(module, user, write=False, path=None, manage_dir=True, follow=False): """ Calculate name of authorized keys file, optionally creating the @@ -359,7 +370,7 @@ def keyfile(module, user, write=False, path=None, manage_dir=True, follow=False) module.fail_json(msg="Failed to create directory %s : %s" % (sshdir, to_native(e))) if module.selinux_enabled(): module.set_default_selinux_context(sshdir, False) - os.chown(sshdir, uid, gid) + os.chown(sshdir, uid, gid, follow_symlinks=follow) os.chmod(sshdir, int('0700', 8)) if not os.path.exists(keysfile): @@ -367,16 +378,13 @@ def keyfile(module, user, write=False, path=None, manage_dir=True, follow=False) if not os.path.exists(basedir): os.makedirs(basedir) - f = None - try: - f = open(keysfile, "w") # touches file so we can set ownership and perms - finally: - f.close() + fd = _safe_open_write(module, keysfile, follow) + os.close(fd) if module.selinux_enabled(): module.set_default_selinux_context(keysfile, False) try: - os.chown(keysfile, uid, gid) + os.chown(keysfile, uid, gid, follow_symlinks=follow) os.chmod(keysfile, int('0600', 8)) except OSError: pass @@ -607,7 +615,7 @@ def enforce_state(module, params): # check current state -- just get the filename, don't create file do_write = False - params["keyfile"] = keyfile(module, user, do_write, path, manage_dir) + params["keyfile"] = keyfile(module, user, do_write, path, manage_dir, follow) existing_content = readfile(module, params["keyfile"]) existing_keys = parsekeys(module, existing_content) @@ -696,6 +704,11 @@ def enforce_state(module, params): if not module.check_mode: writefile(module, filename, new_content) + user_entry = pwd.getpwnam(user) + uid = user_entry.pw_uid + gid = user_entry.pw_gid + os.chown(filename, uid, gid, follow_symlinks=follow) + os.chmod(filename, int('0600', 8)) params['changed'] = True return params diff --git a/tests/integration/targets/authorized_key/tasks/check_symlink.yml b/tests/integration/targets/authorized_key/tasks/check_symlink.yml new file mode 100644 index 0000000..7572fbd --- /dev/null +++ b/tests/integration/targets/authorized_key/tasks/check_symlink.yml @@ -0,0 +1,22 @@ +--- +# +# Check: keysfile is symlink and follow=false +# +- name: Try to add key with keysfile as symlink + ansible.posix.authorized_key: + user: testuser + key: "{{ rsa_key_basic }}" + state: present + manage_dir: false + follow: false + +- name: Assert target file ownership unchanged + ansible.builtin.stat: + path: /tmp/symlink_test_target/target_file + register: target_file_stat + +- name: Verify target file is still owned by root + ansible.builtin.assert: + that: + - target_file_stat.stat.uid == 0 +... diff --git a/tests/integration/targets/authorized_key/tasks/check_symlink_cleanup.yml b/tests/integration/targets/authorized_key/tasks/check_symlink_cleanup.yml new file mode 100644 index 0000000..37b64d4 --- /dev/null +++ b/tests/integration/targets/authorized_key/tasks/check_symlink_cleanup.yml @@ -0,0 +1,12 @@ +--- +- name: Remove testuser + ansible.builtin.user: + name: testuser + state: absent + remove: true + +- name: Remove symlink target directory + ansible.builtin.file: + path: /tmp/symlink_test_target + state: absent +... diff --git a/tests/integration/targets/authorized_key/tasks/check_symlink_setup.yml b/tests/integration/targets/authorized_key/tasks/check_symlink_setup.yml new file mode 100644 index 0000000..3c21ea4 --- /dev/null +++ b/tests/integration/targets/authorized_key/tasks/check_symlink_setup.yml @@ -0,0 +1,38 @@ +--- +- name: Create testuser for symlink tests + ansible.builtin.user: + name: testuser + create_home: true + register: testuser_result + +- name: Create symlink target directory + ansible.builtin.file: + path: /tmp/symlink_test_target + state: directory + owner: root + mode: '0700' + +- name: Create a target file owned by root + ansible.builtin.copy: + dest: /tmp/symlink_test_target/target_file + content: "sensitive data" + owner: root + mode: '0600' + +- name: Remove .ssh directory if exists + ansible.builtin.file: + path: "{{ testuser_result.home }}/.ssh" + state: absent + +- name: Create .ssh directory + ansible.builtin.file: + path: "{{ testuser_result.home }}/.ssh" + state: directory + mode: '0700' + +- name: Create symlink from authorized_keys to target file + ansible.builtin.file: + src: /tmp/symlink_test_target/target_file + dest: "{{ testuser_result.home }}/.ssh/authorized_keys" + state: link +... diff --git a/tests/integration/targets/authorized_key/tasks/main.yml b/tests/integration/targets/authorized_key/tasks/main.yml index 761b6d5..cd8d658 100644 --- a/tests/integration/targets/authorized_key/tasks/main.yml +++ b/tests/integration/targets/authorized_key/tasks/main.yml @@ -17,7 +17,7 @@ # You should have received a copy of the GNU General Public License # along with Ansible. If not, see . -- name: Setup testing environment +- name: Setup for testing environment ansible.builtin.import_tasks: setup_steps.yml - name: Test for multiple keys handling @@ -37,3 +37,12 @@ - name: Test for permission denied files ansible.builtin.import_tasks: check_permissions.yml + +- name: CVE-2026-11837 Setup for symlink tests + ansible.builtin.import_tasks: check_symlink_setup.yml + +- name: CVE-2026-11837 Test for symlink tests + ansible.builtin.import_tasks: check_symlink.yml + +- name: CVE-2026-11837 Cleanup symlink test + ansible.builtin.import_tasks: check_symlink_cleanup.yml