Repository: pganssle/zoneinfo

Issue:
Enforce that all inputs are relative directories
To avoid path traversal attacks, the PEP requires that inputs be relative and not absolute paths.

Discussion:

Patch:
diff --git a/src/zoneinfo/__init__.py b/src/zoneinfo/__init__.py
index 82f855b..da6e5a6 100644
--- a/src/zoneinfo/__init__.py
+++ b/src/zoneinfo/__init__.py
@@ -1,6 +1,7 @@
-__all__ = ["ZoneInfo", "reset_tzpath", "TZPATH"]
+__all__ = ["ZoneInfo", "reset_tzpath", "TZPATH", "ZoneInfoNotFoundError"]
 
 from . import _tzpath
+from ._common import ZoneInfoNotFoundError
 from ._version import __version__
 
 try:
diff --git a/src/zoneinfo/_common.py b/src/zoneinfo/_common.py
index 6972948..568a487 100644
--- a/src/zoneinfo/_common.py
+++ b/src/zoneinfo/_common.py
@@ -4,15 +4,14 @@ import struct
 def load_tzdata(key):
     import importlib.resources
 
-    # TODO: Proper error for malformed keys?
     components = key.split("/")
     package_name = ".".join(["tzdata.zoneinfo"] + components[:-1])
     resource_name = components[-1]
 
     try:
         return importlib.resources.open_binary(package_name, resource_name)
-    except (ImportError, FileNotFoundError) as e:
-        raise ValueError(f"No time zone found with key {key}") from e
+    except (ImportError, FileNotFoundError, UnicodeEncodeError) as e:
+        raise ZoneInfoNotFoundError(f"No time zone found with key {key}") from e
 
 
 def load_data(fobj):
@@ -155,3 +154,7 @@ class _TZifHeader:
         args = args + struct.unpack(">6l", stream.read(24))
 
         return cls(*args)
+
+
+class ZoneInfoNotFoundError(KeyError):
+    """Exception raised when a ZoneInfo key is not found."""
diff --git a/src/zoneinfo/_tzpath.py b/src/zoneinfo/_tzpath.py
index 4767eda..799eaa0 100644
--- a/src/zoneinfo/_tzpath.py
+++ b/src/zoneinfo/_tzpath.py
@@ -37,6 +37,7 @@ def reset_tzpath(to=None):
 
 def find_tzfile(key):
     """Retrieve the path to a TZif file from a key."""
+    _validate_path(key)
     for search_path in TZPATH:
         filepath = os.path.join(search_path, key)
         if os.path.isfile(filepath):
@@ -45,5 +46,34 @@ def find_tzfile(key):
     return None
 
 
+_TEST_PATH = os.path.normpath(os.path.join("_", "_"))[:-1]
+
+
+def _validate_path(path, _base=_TEST_PATH):
+    if os.path.isabs(path):
+        raise ValueError(
+            f"ZoneInfo keys may not be absolute paths, got: {path}"
+        )
+
+    # We only care about the kinds of path normalizations that would change the
+    # length of the key - e.g. a/../b -> a/b, or a/b/ -> a/b. On Windows,
+    # normpath will also change from a/b to a\b, but that would still preserve
+    # the length.
+    new_path = os.path.normpath(path)
+    if len(new_path) != len(path):
+        raise ValueError(
+            f"ZoneInfo keys must be normalized relative paths, got: {path}"
+        )
+
+    resolved = os.path.normpath(os.path.join(_base, new_path))
+    if not resolved.startswith(_base):
+        raise ValueError(
+            f"ZoneInfo keys must refer to subdirectories of TZPATH, got: {path}"
+        )
+
+
+del _TEST_PATH
+
+
 TZPATH = ()
 reset_tzpath()


Test patch:
diff --git a/tests/test_zoneinfo.py b/tests/test_zoneinfo.py
index a4d78d8..5af2e18 100644
--- a/tests/test_zoneinfo.py
+++ b/tests/test_zoneinfo.py
@@ -196,10 +196,25 @@ class ZoneInfoTest(TzPathUserMixin, ZoneInfoTestBase):
     def test_bad_keys(self):
         bad_keys = [
             "Eurasia/Badzone",  # Plausible but does not exist
+            "BZQ",
+            "America.Los_Angeles",
             "🇨🇦",  # Non-ascii
             "America/New\ud800York",  # Contains surrogate character
         ]
 
+        for bad_key in bad_keys:
+            with self.assertRaises(self.module.ZoneInfoNotFoundError):
+                self.klass(bad_key)
+
+    def test_bad_keys_paths(self):
+        bad_keys = [
+            "/America/Los_Angeles",  # Absolute path
+            "America/Los_Angeles/",  # Trailing slash - not normalized
+            "../zoneinfo/America/Los_Angeles",  # Traverses above TZPATH
+            "America/../America/Los_Angeles",  # Not normalized
+            "America/./Los_Angeles",
+        ]
+
         for bad_key in bad_keys:
             with self.assertRaises(ValueError):
                 self.klass(bad_key)