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

Targeted fix for installing roles with symlinks containing '..' #82165

Merged
merged 3 commits into from Nov 30, 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
2 changes: 2 additions & 0 deletions changelogs/fragments/ansible-galaxy-role-install-symlink.yml
@@ -0,0 +1,2 @@
bugfixes:
- ansible-galaxy role install - normalize tarfile paths and symlinks using ``ansible.utils.path.unfrackpath`` and consider them valid as long as the realpath is in the tarfile's role directory (https://github.com/ansible/ansible/issues/81965).
71 changes: 35 additions & 36 deletions lib/ansible/galaxy/role.py
Expand Up @@ -41,6 +41,7 @@
from ansible.module_utils.urls import open_url
from ansible.playbook.role.requirement import RoleRequirement
from ansible.utils.display import Display
from ansible.utils.path import is_subpath, unfrackpath

display = Display()

Expand Down Expand Up @@ -392,43 +393,41 @@ def install(self):
# we only extract files, and remove any relative path
# bits that might be in the file for security purposes
# and drop any containing directory, as mentioned above
if member.isreg() or member.issym():
for attr in ('name', 'linkname'):
attr_value = getattr(member, attr, None)
if not attr_value:
continue
n_attr_value = to_native(attr_value)
n_archive_parent_dir = to_native(archive_parent_dir)
n_parts = n_attr_value.replace(n_archive_parent_dir, "", 1).split(os.sep)
n_final_parts = []
for n_part in n_parts:
# TODO if the condition triggers it produces a broken installation.
# It will create the parent directory as an empty file and will
# explode if the directory contains valid files.
# Leaving this as is since the whole module needs a rewrite.
#
# Check if we have any files with illegal names,
# and display a warning if so. This could help users
# to debug a broken installation.
if not n_part:
continue
if n_part == '..':
display.warning(f"Illegal filename '{n_part}': '..' is not allowed")
continue
if n_part.startswith('~'):
display.warning(f"Illegal filename '{n_part}': names cannot start with '~'")
continue
if '$' in n_part:
display.warning(f"Illegal filename '{n_part}': names cannot contain '$'")
continue
n_final_parts.append(n_part)
setattr(member, attr, os.path.join(*n_final_parts))

if _check_working_data_filter():
# deprecated: description='extract fallback without filter' python_version='3.11'
role_tar_file.extract(member, to_native(self.path), filter='data') # type: ignore[call-arg]
if not (member.isreg() or member.issym()):
continue

for attr in ('name', 'linkname'):
if not (attr_value := getattr(member, attr, None)):
continue

if attr_value.startswith(os.sep) and not is_subpath(attr_value, archive_parent_dir):
err = f"Invalid {attr} for tarfile member: path {attr_value} is not a subpath of the role {archive_parent_dir}"
raise AnsibleError(err)
bcoca marked this conversation as resolved.
Show resolved Hide resolved

if attr == 'linkname':
# Symlinks are relative to the link
relative_to_archive_dir = os.path.dirname(getattr(member, 'name', ''))
archive_dir_path = os.path.join(archive_parent_dir, relative_to_archive_dir, attr_value)
else:
role_tar_file.extract(member, to_native(self.path))
# Normalize paths that start with the archive dir
attr_value = attr_value.replace(archive_parent_dir, "", 1)
attr_value = os.path.join(*attr_value.split(os.sep)) # remove leading os.sep
archive_dir_path = os.path.join(archive_parent_dir, attr_value)

resolved_archive = unfrackpath(archive_parent_dir)
resolved_path = unfrackpath(archive_dir_path)
if not is_subpath(resolved_path, resolved_archive):
err = f"Invalid {attr} for tarfile member: path {resolved_path} is not a subpath of the role {resolved_archive}"
raise AnsibleError(err)

relative_path = os.path.join(*resolved_path.replace(resolved_archive, "", 1).split(os.sep)) or '.'
setattr(member, attr, relative_path)

if _check_working_data_filter():
# deprecated: description='extract fallback without filter' python_version='3.11'
role_tar_file.extract(member, to_native(self.path), filter='data') # type: ignore[call-arg]
else:
role_tar_file.extract(member, to_native(self.path))

# write out the install info file for later use
self._write_galaxy_install_info()
Expand Down
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import argparse
import os
import pathlib
import tarfile
import tempfile
Expand All @@ -19,6 +20,15 @@ def main() -> None:
create_archive(args.archive, args.content, args.target)


def generate_files_from_path(path):
if os.path.isdir(path):
for subpath in os.listdir(path):
_path = os.path.join(path, subpath)
yield from generate_files_from_path(_path)
elif os.path.isfile(path):
yield pathlib.Path(path)


def create_archive(archive_path: pathlib.Path, content_path: pathlib.Path, target_path: pathlib.Path) -> None:
with (
tarfile.open(name=archive_path, mode='w') as role_archive,
Expand All @@ -36,10 +46,15 @@ def create_archive(archive_path: pathlib.Path, content_path: pathlib.Path, targe
role_archive.add(meta_main_path)
role_archive.add(symlink_path)

content_tarinfo = role_archive.gettarinfo(content_path, str(symlink_path))
for path in generate_files_from_path(content_path):
if path == content_path:
arcname = str(symlink_path)
else:
arcname = os.path.join(temp_dir_path, path)

with content_path.open('rb') as content_file:
role_archive.addfile(content_tarinfo, content_file)
content_tarinfo = role_archive.gettarinfo(path, arcname)
with path.open('rb') as file_content:
role_archive.addfile(content_tarinfo, file_content)


if __name__ == '__main__':
Expand Down
Expand Up @@ -23,6 +23,9 @@
command:
cmd: ansible-galaxy role install --roles-path '{{ remote_tmp_dir }}/dir-traversal/roles' dangerous.tar
chdir: '{{ remote_tmp_dir }}/dir-traversal/source'
environment:
ANSIBLE_NOCOLOR: True
ANSIBLE_FORCE_COLOR: False
ignore_errors: true
register: galaxy_install_dangerous

Expand All @@ -42,3 +45,86 @@
- dangerous_overwrite_content.content|default('')|b64decode == ''
- not dangerous_overwrite_stat.stat.exists
- galaxy_install_dangerous is failed
- "'is not a subpath of the role' in (galaxy_install_dangerous.stderr | regex_replace('\n', ' '))"

- name: remove tarfile for next test
file:
path: '{{ item }}'
state: absent
loop:
- '{{ remote_tmp_dir }}/dir-traversal/source/dangerous.tar'
- '{{ remote_tmp_dir }}/dir-traversal/roles/dangerous.tar'

- name: build dangerous dir traversal role that includes .. in the symlink path
script:
chdir: '{{ remote_tmp_dir }}/dir-traversal/source'
cmd: create-role-archive.py dangerous.tar content.txt {{ remote_tmp_dir }}/dir-traversal/source/../target/target-file-to-overwrite.txt
executable: '{{ ansible_playbook_python }}'

- name: install dangerous role
command:
cmd: 'ansible-galaxy role install --roles-path {{ remote_tmp_dir }}/dir-traversal/roles dangerous.tar'
chdir: '{{ remote_tmp_dir }}/dir-traversal/source'
environment:
ANSIBLE_NOCOLOR: True
ANSIBLE_FORCE_COLOR: False
ignore_errors: true
register: galaxy_install_dangerous

- name: check for overwritten file
stat:
path: '{{ remote_tmp_dir }}/dir-traversal/target/target-file-to-overwrite.txt'
register: dangerous_overwrite_stat

- name: get overwritten content
slurp:
path: '{{ remote_tmp_dir }}/dir-traversal/target/target-file-to-overwrite.txt'
register: dangerous_overwrite_content
when: dangerous_overwrite_stat.stat.exists

- assert:
that:
- dangerous_overwrite_content.content|default('')|b64decode == ''
- not dangerous_overwrite_stat.stat.exists
- galaxy_install_dangerous is failed
- "'is not a subpath of the role' in (galaxy_install_dangerous.stderr | regex_replace('\n', ' '))"

- name: remove tarfile for next test
file:
path: '{{ remote_tmp_dir }}/dir-traversal/source/dangerous.tar'
state: absent

- name: build dangerous dir traversal role that includes .. in the relative symlink path
script:
chdir: '{{ remote_tmp_dir }}/dir-traversal/source'
cmd: create-role-archive.py dangerous_rel.tar content.txt ../context.txt

- name: install dangerous role with relative symlink
command:
cmd: 'ansible-galaxy role install --roles-path {{ remote_tmp_dir }}/dir-traversal/roles dangerous_rel.tar'
chdir: '{{ remote_tmp_dir }}/dir-traversal/source'
environment:
ANSIBLE_NOCOLOR: True
ANSIBLE_FORCE_COLOR: False
ignore_errors: true
register: galaxy_install_dangerous

- name: check for symlink outside role
stat:
path: "{{ remote_tmp_dir | realpath }}/dir-traversal/roles/symlink"
register: symlink_outside_role

- assert:
that:
- not symlink_outside_role.stat.exists
- galaxy_install_dangerous is failed
- "'is not a subpath of the role' in (galaxy_install_dangerous.stderr | regex_replace('\n', ' '))"

- name: remove test directories
file:
path: '{{ remote_tmp_dir }}/dir-traversal/{{ item }}'
state: absent
loop:
- source
- target
- roles
16 changes: 10 additions & 6 deletions test/integration/targets/ansible-galaxy-role/tasks/main.yml
Expand Up @@ -25,14 +25,17 @@
- name: Valid role archive
command: "tar cf {{ remote_tmp_dir }}/valid-role.tar {{ remote_tmp_dir }}/role.d"

- name: Invalid file
copy:
content: ""
- name: Add invalid symlink
file:
state: link
src: "~/invalid"
dest: "{{ remote_tmp_dir }}/role.d/tasks/~invalid.yml"
force: yes

- name: Invalid file
copy:
content: ""
- name: Add another invalid symlink
file:
state: link
src: "/"
dest: "{{ remote_tmp_dir }}/role.d/tasks/invalid$name.yml"

- name: Valid requirements file
Expand Down Expand Up @@ -66,3 +69,4 @@
command: ansible-galaxy role remove invalid-testrole

- import_tasks: dir-traversal.yml
- import_tasks: valid-role-symlinks.yml
@@ -0,0 +1,78 @@
- name: create test directories
file:
path: '{{ remote_tmp_dir }}/dir-traversal/{{ item }}'
state: directory
loop:
- source
- target
- roles

- name: create subdir in the role content to test relative symlinks
file:
dest: '{{ remote_tmp_dir }}/dir-traversal/source/role_subdir'
state: directory

- copy:
dest: '{{ remote_tmp_dir }}/dir-traversal/source/role_subdir/.keep'
content: ''

- set_fact:
installed_roles: "{{ remote_tmp_dir | realpath }}/dir-traversal/roles"

- name: build role with symlink to a directory in the role
script:
chdir: '{{ remote_tmp_dir }}/dir-traversal/source'
cmd: create-role-archive.py safe-link-dir.tar ./ role_subdir/..
executable: '{{ ansible_playbook_python }}'

- name: install role successfully
command:
cmd: 'ansible-galaxy role install --roles-path {{ remote_tmp_dir }}/dir-traversal/roles safe-link-dir.tar'
chdir: '{{ remote_tmp_dir }}/dir-traversal/source'
register: galaxy_install_ok

- name: check for the directory symlink in the role
stat:
path: "{{ installed_roles }}/safe-link-dir.tar/symlink"
register: symlink_in_role

- assert:
that:
- symlink_in_role.stat.exists
- symlink_in_role.stat.lnk_source == installed_roles + '/safe-link-dir.tar'

- name: remove tarfile for next test
file:
path: '{{ remote_tmp_dir }}/dir-traversal/source/safe-link-dir.tar'
state: absent

- name: build role with safe relative symlink
script:
chdir: '{{ remote_tmp_dir }}/dir-traversal/source'
cmd: create-role-archive.py safe.tar ./ role_subdir/../context.txt
executable: '{{ ansible_playbook_python }}'

- name: install role successfully
command:
cmd: 'ansible-galaxy role install --roles-path {{ remote_tmp_dir }}/dir-traversal/roles safe.tar'
chdir: '{{ remote_tmp_dir }}/dir-traversal/source'
register: galaxy_install_ok

- name: check for symlink in role
stat:
path: "{{ installed_roles }}/safe.tar/symlink"
register: symlink_in_role

- assert:
that:
- symlink_in_role.stat.exists
- symlink_in_role.stat.lnk_source == installed_roles + '/safe.tar/context.txt'

- name: remove test directories
file:
path: '{{ remote_tmp_dir }}/dir-traversal/{{ item }}'
state: absent
loop:
- source
- target
- roles