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.
| Package | Affected versions | Patched versions |
|---|---|---|
scitokensPyPI | < 1.9.7 | 1.9.7 |
Affected products
1Patches
12d1cc9e42bc9Merge pull request #230 from djw8605/fix-path-transversal
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- github.com/scitokens/scitokens/commit/2d1cc9e42bc944fe0bbc429b85d166e7156d53f9nvdPatchWEB
- github.com/scitokens/scitokens/security/advisories/GHSA-3x2w-63fp-3qvwnvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-3x2w-63fp-3qvwghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-32727ghsaADVISORY
- github.com/scitokens/scitokens/pull/230nvdIssue TrackingWEB
- github.com/scitokens/scitokens/releases/tag/v1.9.7nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.