CVE-2020-15141: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: In openapi-python-client before version 0.5.3, there is a path traversal vulnerability. If a user generated a client using a maliciously crafted OpenAPI document, it is possible for generated files to be placed in arbitrary locations on disk.
CWE description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
CVSS v3 base score: 4.1
Published: 2020-08-14T17:15Z
Repository: https://github.com/triaxtec/openapi-python-client
Language: Python
Files changed: end_to_end_tests/regen_golden_master.py, openapi_python_client/__init__.py, openapi_python_client/templates/endpoint_macros.pyi, openapi_python_client/utils.py, tests/test___init__.py, tests/test_templates/conftest.py, tests/test_utils.py
Commit date: 2020-08-13 12:57:06 -0400
Commit message: Merge pull request from GHSA-7wgr-7666-7pwj

* All strings used as file/directory names are now sanitized to address the path traversal vulnerabilities

* Switched calls to utils.spinal_case to utils.kebab_case

Co-authored-by: Ethan Mann <emann@triaxtec.com>
Diff: diff --git a/end_to_end_tests/regen_golden_master.py b/end_to_end_tests/regen_golden_master.py
index ca392ab..1c8ea1c 100644
--- a/end_to_end_tests/regen_golden_master.py
+++ b/end_to_end_tests/regen_golden_master.py
@@ -5,13 +5,10 @@ from pathlib import Path
 from typer.testing import CliRunner
 
 from openapi_python_client.cli import app
 
 if __name__ == "__main__":
-    from .fastapi_app import generate_openapi_json
-
-    generate_openapi_json()
     runner = CliRunner()
     openapi_path = Path(__file__).parent / "fastapi_app" / "openapi.json"
     gm_path = Path(__file__).parent / "golden-master"
     shutil.rmtree(gm_path, ignore_errors=True)
     output_path = Path.cwd() / "my-test-api-client"
diff --git a/openapi_python_client/__init__.py b/openapi_python_client/__init__.py
index 8c3c65f..d8846ac 100644
--- a/openapi_python_client/__init__.py
+++ b/openapi_python_client/__init__.py
@@ -81,19 +81,19 @@ def _get_document(*, url: Optional[str], path: Optional[Path]) -> Union[Dict[str
     except yaml.YAMLError:
         return GeneratorError(header="Invalid YAML from provided source")
 
 
 class Project:
-    TEMPLATE_FILTERS = {"snakecase": utils.snake_case, "spinalcase": utils.spinal_case}
+    TEMPLATE_FILTERS = {"snakecase": utils.snake_case, "kebabcase": utils.kebab_case}
     project_name_override: Optional[str] = None
     package_name_override: Optional[str] = None
 
     def __init__(self, *, openapi: GeneratorData) -> None:
         self.openapi: GeneratorData = openapi
         self.env: Environment = Environment(loader=PackageLoader(__package__), trim_blocks=True, lstrip_blocks=True)
 
-        self.project_name: str = self.project_name_override or f"{openapi.title.replace(' ', '-').lower()}-client"
+        self.project_name: str = self.project_name_override or f"{utils.kebab_case(openapi.title).lower()}-client"
         self.project_dir: Path = Path.cwd() / self.project_name
 
         self.package_name: str = self.package_name_override or self.project_name.replace("-", "_")
         self.package_dir: Path = self.project_dir / self.package_name
         self.package_description: str = f"A client library for accessing {self.openapi.title}"
@@ -229,9 +229,10 @@ class Project:
         api_errors.write_text(errors_template.render())
 
         endpoint_template = self.env.get_template("endpoint_module.pyi")
         async_endpoint_template = self.env.get_template("async_endpoint_module.pyi")
         for tag, collection in self.openapi.endpoint_collections_by_tag.items():
+            tag = utils.snake_case(tag)
             module_path = api_dir / f"{tag}.py"
             module_path.write_text(endpoint_template.render(collection=collection))
             async_module_path = async_api_dir / f"{tag}.py"
             async_module_path.write_text(async_endpoint_template.render(collection=collection))
diff --git a/openapi_python_client/templates/endpoint_macros.pyi b/openapi_python_client/templates/endpoint_macros.pyi
index 555968e..50529a1 100644
--- a/openapi_python_client/templates/endpoint_macros.pyi
+++ b/openapi_python_client/templates/endpoint_macros.pyi
@@ -1,13 +1,13 @@
 {% macro header_params(endpoint) %}
 {% if endpoint.header_parameters %}
     {% for parameter in endpoint.header_parameters %}
         {% if parameter.required %}
-headers["{{ parameter.python_name | spinalcase}}"] = {{ parameter.python_name }}
+headers["{{ parameter.python_name | kebabcase}}"] = {{ parameter.python_name }}
         {% else %}
 if {{ parameter.python_name }} is not None:
-    headers["{{ parameter.python_name | spinalcase}}"] = {{ parameter.python_name }}
+    headers["{{ parameter.python_name | kebabcase}}"] = {{ parameter.python_name }}
         {% endif %}
     {% endfor %}
 {% endif %}
 {% endmacro %}
 
diff --git a/openapi_python_client/utils.py b/openapi_python_client/utils.py
index 09ab12e..bf20931 100644
--- a/openapi_python_client/utils.py
+++ b/openapi_python_client/utils.py
@@ -1,17 +1,25 @@
 import re
 
 import stringcase
 
 
-def snake_case(value: str) -> str:
+def _sanitize(value: str) -> str:
+    return re.sub(r"[^\w _-]+", "", value)
+
+
+def group_title(value: str) -> str:
     value = re.sub(r"([A-Z]{2,})([A-Z][a-z]|[ -_]|$)", lambda m: m.group(1).title() + m.group(2), value.strip())
     value = re.sub(r"(^|[ _-])([A-Z])", lambda m: m.group(1) + m.group(2).lower(), value)
-    return stringcase.snakecase(value)
+    return value
+
+
+def snake_case(value: str) -> str:
+    return stringcase.snakecase(group_title(_sanitize(value)))
 
 
 def pascal_case(value: str) -> str:
-    return stringcase.pascalcase(value)
+    return stringcase.pascalcase(_sanitize(value))
 
 
-def spinal_case(value: str) -> str:
-    return stringcase.spinalcase(value)
+def kebab_case(value: str) -> str:
+    return stringcase.spinalcase(group_title(_sanitize(value)))
diff --git a/tests/test___init__.py b/tests/test___init__.py
index d920e69..15ee5e3 100644
--- a/tests/test___init__.py
+++ b/tests/test___init__.py
@@ -484,12 +484,12 @@ class TestProject:
         from jinja2 import Template
 
         from openapi_python_client import GeneratorData, Project
 
         openapi = mocker.MagicMock(autospec=GeneratorData, title="My Test API")
-        tag_1 = mocker.MagicMock(autospec=str)
-        tag_2 = mocker.MagicMock(autospec=str)
+        tag_1 = "a_tag"
+        tag_2 = "another_tag"
         collection_1 = mocker.MagicMock()
         collection_2 = mocker.MagicMock()
         openapi.endpoint_collections_by_tag = {tag_1: collection_1, tag_2: collection_2}
         project = Project(openapi=openapi)
         project.package_dir = mocker.MagicMock()
diff --git a/tests/test_templates/conftest.py b/tests/test_templates/conftest.py
index a11db3c..09fd63f 100644
--- a/tests/test_templates/conftest.py
+++ b/tests/test_templates/conftest.py
@@ -4,9 +4,9 @@ from jinja2 import Environment, PackageLoader
 
 @pytest.fixture(scope="session")
 def env() -> Environment:
     from openapi_python_client import utils
 
-    TEMPLATE_FILTERS = {"snakecase": utils.snake_case, "spinalcase": utils.spinal_case}
+    TEMPLATE_FILTERS = {"snakecase": utils.snake_case, "kebabcase": utils.kebab_case}
     env = Environment(loader=PackageLoader("openapi_python_client"), trim_blocks=True, lstrip_blocks=True)
     env.filters.update(TEMPLATE_FILTERS)
     return env
diff --git a/tests/test_utils.py b/tests/test_utils.py
index a7aa094..3bed213 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -18,7 +18,7 @@ def test_snake_case_from_pascal():
 
 def test_snake_case_from_camel():
     assert utils.snake_case("httpResponseLowerCamel") == "http_response_lower_camel"
 
 
-def test_spinal_case():
-    assert utils.spinal_case("keep_alive") == "keep-alive"
+def test_kebab_case():
+    assert utils.kebab_case("keep_alive") == "keep-alive"

Vulnerable code:     from .fastapi_app import generate_openapi_json

    generate_openapi_json()
    TEMPLATE_FILTERS = {"snakecase": utils.snake_case, "spinalcase": utils.spinal_case}
        self.project_name: str = self.project_name_override or f"{openapi.title.replace(' ', '-').lower()}-client"
headers["{{ parameter.python_name | spinalcase}}"] = {{ parameter.python_name }}
    headers["{{ parameter.python_name | spinalcase}}"] = {{ parameter.python_name }}
def snake_case(value: str) -> str:
    return stringcase.snakecase(value)
    return stringcase.pascalcase(value)
def spinal_case(value: str) -> str:
    return stringcase.spinalcase(value)
        tag_1 = mocker.MagicMock(autospec=str)
        tag_2 = mocker.MagicMock(autospec=str)
    TEMPLATE_FILTERS = {"snakecase": utils.snake_case, "spinalcase": utils.spinal_case}
def test_spinal_case():
    assert utils.spinal_case("keep_alive") == "keep-alive"
Fixed code:     TEMPLATE_FILTERS = {"snakecase": utils.snake_case, "kebabcase": utils.kebab_case}
        self.project_name: str = self.project_name_override or f"{utils.kebab_case(openapi.title).lower()}-client"
            tag = utils.snake_case(tag)
headers["{{ parameter.python_name | kebabcase}}"] = {{ parameter.python_name }}
    headers["{{ parameter.python_name | kebabcase}}"] = {{ parameter.python_name }}
def _sanitize(value: str) -> str:
    return re.sub(r"[^\w _-]+", "", value)


def group_title(value: str) -> str:
    return value


def snake_case(value: str) -> str:
    return stringcase.snakecase(group_title(_sanitize(value)))
    return stringcase.pascalcase(_sanitize(value))
def kebab_case(value: str) -> str:
    return stringcase.spinalcase(group_title(_sanitize(value)))
        tag_1 = "a_tag"
        tag_2 = "another_tag"
    TEMPLATE_FILTERS = {"snakecase": utils.snake_case, "kebabcase": utils.kebab_case}
def test_kebab_case():
    assert utils.kebab_case("keep_alive") == "keep-alive"