File CVE-2026-32727.patch of Package python-scitokens

From bd7e8c08690a3ed13431f04e262f00cd0fb8a9d3 Mon Sep 17 00:00:00 2001
From: Derek Weitzel <djw8605@gmail.com>
Date: Fri, 13 Mar 2026 14:33:38 -0500
Subject: [PATCH 1/3] Enhance path traversal protection in Enforcer with
 additional checks and unit tests

---
 src/scitokens/scitokens.py | 29 +++++++++++-
 tests/test_scitokens.py    | 95 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 122 insertions(+), 2 deletions(-)

Index: scitokens-1.8.1/src/scitokens/scitokens.py
===================================================================
--- scitokens-1.8.1.orig/src/scitokens/scitokens.py
+++ scitokens-1.8.1/src/scitokens/scitokens.py
@@ -10,6 +10,7 @@ import time
 
 import os
 import jwt
+import re
 from . import urltools
 import logging
 
@@ -462,7 +463,7 @@ class InvalidPathError(EnforcementError)
     Test paths must be absolute paths (start with '/')
     """
 
-class InvalidAuthorizationResource(EnforcementError):
+class InvalidAuthorizationResource(ValidationFailure, EnforcementError):
     """
     A scope was encountered with an invalid authorization.
 
@@ -674,12 +675,36 @@ class Enforcer(object):
             path = info[1]
             if not path.startswith("/"):
                 raise InvalidAuthorizationResource("Token contains a relative path in scope")
-            norm_path = urltools.normalize_path(path)
+            norm_path = self._normalize_scope_path(path)
         else:
             norm_path = '/'
         return (authz, norm_path)
 
     @staticmethod
+    def _decode_scope_path_segment(segment):
+        normalized_segment = re.sub(
+            r"%([0-9A-Fa-f]{2})",
+            lambda match: "%" + match.group(1).lower(),
+            segment,
+        )
+        return urltools.unquote(normalized_segment, exceptions='/?+#')
+
+    @classmethod
+    def _normalize_scope_path(cls, path):
+        for segment in path.split("/"):
+            if cls._decode_scope_path_segment(segment) == "..":
+                raise InvalidAuthorizationResource("Token contains path traversal in scope")
+        normalized = urltools.normalize_path(path)
+        # Defense-in-depth: verify the normalized path hasn't escaped root
+        # via double-encoding or other tricks that bypass the segment check.
+        if not normalized.startswith("/"):
+            raise InvalidAuthorizationResource("Token contains path traversal in scope")
+        for segment in normalized.split("/"):
+            if segment == "..":
+                raise InvalidAuthorizationResource("Token contains path traversal in scope")
+        return normalized
+
+    @staticmethod
     def _scope_path_matches(requested_path, allowed_path):
         if allowed_path == '/':
             return True
Index: scitokens-1.8.1/tests/test_scitokens.py
===================================================================
--- scitokens-1.8.1.orig/tests/test_scitokens.py
+++ scitokens-1.8.1/tests/test_scitokens.py
@@ -213,6 +213,28 @@ class TestEnforcer(unittest.TestCase):
         self.assertTrue(enf.test(self._token, "read", "//john//file"), msg=enf.last_failure)
         self.assertFalse(enf.test(self._token, "read", "//johnathan"), msg=enf.last_failure)
 
+    def test_enforce_scp_path_traversal(self):
+        enf = scitokens.Enforcer(self._test_issuer)
+        enf.add_validator("foo", self.always_accept)
+
+        bad_scopes = [
+            ("read:/home/user1/..", "/home/user2"),
+            ("read:/anything/..", "/etc/passwd"),
+            ("read:/foo/%2e%2e/bar", "/bar"),
+            ("read:/foo/.%2e/bar", "/bar"),
+            ("read:/foo/%2e./bar", "/bar"),
+            ("read:/foo/%2E%2E/bar", "/bar"),
+        ]
+
+        for scope, requested_path in bad_scopes:
+            self._token["scp"] = scope
+            self.assertFalse(enf.test(self._token, "read", requested_path), msg=enf.last_failure)
+            self.assertIn("path traversal", enf.last_failure)
+
+        self._token["scp"] = "read:/foo/%2e%2e/bar"
+        with self.assertRaises(scitokens.scitokens.InvalidAuthorizationResource):
+            enf.generate_acls(self._token)
+
     def test_enforce_scope(self):
         """
         Test the Enforcer object.
@@ -265,6 +287,74 @@ class TestEnforcer(unittest.TestCase):
         self.assertTrue(enf.test(self._token, "read", "//john//file"), msg=enf.last_failure)
         self.assertFalse(enf.test(self._token, "read", "//johnathan"), msg=enf.last_failure)
 
+    def test_enforce_scope_path_traversal(self):
+        enf = scitokens.Enforcer(self._test_issuer)
+        enf.add_validator("foo", self.always_accept)
+
+        bad_scopes = [
+            ("read:/home/user1/..", "/home/user2"),
+            ("read:/anything/..", "/etc/passwd"),
+            ("read:/foo/%2e%2e/bar", "/bar"),
+            ("read:/foo/.%2e/bar", "/bar"),
+            ("read:/foo/%2e./bar", "/bar"),
+            ("read:/foo/%2E%2E/bar", "/bar"),
+        ]
+
+        for scope, requested_path in bad_scopes:
+            self._token["scope"] = scope
+            self.assertFalse(enf.test(self._token, "read", requested_path), msg=enf.last_failure)
+            self.assertIn("path traversal", enf.last_failure)
+
+        self._token["scope"] = "read:/foo/%2e%2e/bar"
+        with self.assertRaises(scitokens.scitokens.InvalidAuthorizationResource):
+            enf.generate_acls(self._token)
+
+    def test_enforce_scope_path_traversal_double_encoded(self):
+        """
+        Defense-in-depth: double-encoded and other encoding variations must
+        not allow path traversal even if the pre-normalization segment check
+        doesn't catch them.
+        """
+        enf = scitokens.Enforcer(self._test_issuer)
+        enf.add_validator("foo", self.always_accept)
+
+        # Double-encoded '..' (%252e%252e decodes once to %2e%2e)
+        # These should either be caught or treated as opaque literal segments
+        # — never resolved to actual '..' traversal.
+        double_encoded_scopes = [
+            "read:/foo/%252e%252e/bar",
+            "read:/foo/%252E%252E/bar",
+            "read:/foo/%252e./bar",
+            "read:/foo/.%252e/bar",
+        ]
+        for scope in double_encoded_scopes:
+            self._token["scope"] = scope
+            # Must not grant access to /bar (the traversed path)
+            self.assertFalse(
+                enf.test(self._token, "read", "/bar"),
+                msg="Scope {!r} should not grant access to /bar".format(scope),
+            )
+
+    def test_normalize_scope_path_rejects_traversal(self):
+        """
+        Test that _normalize_scope_path rejects traversal and encoded
+        traversal paths, and still accepts benign normalized paths.
+        """
+        enforcer_cls = scitokens.scitokens.Enforcer
+
+        # These should all be rejected
+        for bad_path in ["/a/../b", "/a/%2e%2e/b", "/a/.%2e/b", "/a/%2e./b"]:
+            with self.assertRaises(
+                scitokens.scitokens.InvalidAuthorizationResource,
+                msg="Path {!r} should be rejected".format(bad_path),
+            ):
+                enforcer_cls._normalize_scope_path(bad_path)
+
+        # Valid paths must still work
+        for good_path in ["/a/b/c", "/a/b/../c".replace("..", "safe"), "/", "/a/"]:
+            result = enforcer_cls._normalize_scope_path(good_path)
+            self.assertTrue(result.startswith("/"))
+
 
     def test_aud(self):
         """
@@ -373,6 +463,10 @@ class TestEnforcer(unittest.TestCase):
         with self.assertRaises(scitokens.scitokens.InvalidAuthorizationResource):
             print(enf.generate_acls(self._token))
 
+        self._token['scope'] = 'read:/foo/%2e%2e/bar'
+        with self.assertRaises(scitokens.scitokens.InvalidAuthorizationResource):
+            enf.generate_acls(self._token)
+
     def test_sub(self):
         """
         Verify that tokens with the `sub` set are accepted.
openSUSE Build Service is sponsored by