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.