VYPR
High severity8.1NVD Advisory· Published Mar 31, 2026· Updated Apr 3, 2026

CVE-2026-32727

CVE-2026-32727

Description

SciTokens is a reference library for generating and using SciTokens. Prior to version 1.9.7, the Enforcer is vulnerable to a path traversal attack where an attacker can use dot-dot (..) in the scope claim of a token to escape the intended directory restriction. This occurs because the library normalizes both the authorized path (from the token) and the requested path (from the application) before comparing them using startswith. This issue has been patched in version 1.9.7.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
scitokensPyPI
< 1.9.71.9.7

Affected products

1

Patches

1
2d1cc9e42bc9

Merge pull request #230 from djw8605/fix-path-transversal

https://github.com/scitokens/scitokensDerek WeitzelMar 13, 2026via ghsa
2 files changed · +121 2
  • src/scitokens/scitokens.py+27 2 modified
    @@ -10,6 +10,7 @@
     
     import os
     import jwt
    +import re
     from . import urltools
     import logging
     
    @@ -461,7 +462,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.
     
    @@ -678,11 +679,35 @@ def _check_scope(self, scope):
                 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 == '/':
    
  • tests/test_scitokens.py+94 0 modified
    @@ -221,6 +221,28 @@ def test_enforce_scp_path_boundaries(self):
             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_issuer(self):
             """
             Test the issuer claim, with support for multiple valid issuers.
    @@ -303,6 +325,74 @@ def test_enforce_scope_path_boundaries(self):
             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):
             """
    @@ -411,6 +501,10 @@ def test_gen_acls(self):
             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.
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.