From dccae8a923c2de2b9f91dea39c8ba431e8143deb Mon Sep 17 00:00:00 2001 From: Mradul Pal Date: Sat, 19 Sep 2026 13:24:54 +0530 Subject: [PATCH 01/12] scope-validation: Add validateScope() method with path-prefix matching --- .../access_control/access_control.cc | 48 ++++++++++++++++++- .../access_control/access_control.h | 13 ++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/plugins/experimental/access_control/access_control.cc b/plugins/experimental/access_control/access_control.cc index 0cfd8daac91..080ced695f2 100644 --- a/plugins/experimental/access_control/access_control.cc +++ b/plugins/experimental/access_control/access_control.cc @@ -29,6 +29,28 @@ size_t calcMessageDigest(const StringView hf, const char *secret, const char *message, size_t messageLen, char *buffer, size_t len); const char *getSecretMap(const StringMap &map, const StringView &key, size_t &secretSize); +static String +normalizePath(StringView path) +{ + String normalized; + normalized.reserve(path.size() + 1); + + normalized.push_back('/'); + for (char ch : path) { + if (ch == '/') { + if (normalized.back() != '/') { + normalized.push_back('/'); + } + } else { + normalized.push_back(ch); + } + } + if (normalized.size() > 1 && normalized.back() == '/') { + normalized.pop_back(); + } + return normalized; +} + /* AccessToken ***************************************************************************************************** */ AccessToken::AccessToken(const StringMap &secretsMap, bool enableDebug) : _debug(enableDebug), _secretsMap(secretsMap) {} @@ -61,8 +83,7 @@ AccessToken::validate(const StringView token, time_t time) return _state; } - /** @todo: validate scope eventually */ - + /* Note that scope validation is performed against the request path during transaction enforcement */ return _state; } @@ -470,6 +491,29 @@ accessTokenStatusToString(const AccessTokenStatus &state) return s; } +/** + * Validates the request path against the token scope using normalized segment boundaries. + */ +bool +validateScope(StringView requestPath, StringView scope) +{ + if (scope.empty()) { + return true; + } + String normRequestPath = normalizePath(requestPath); + String normScope = normalizePath(scope); + if (normScope == "/") { + return true; + } + if (normRequestPath == normScope) { + return true; + } + if (normRequestPath.starts_with(normScope) && normRequestPath[normScope.length()] == '/') { + return true; + } + return false; +} + /* Debug dump of the token */ std::ostream & operator<<(std::ostream &os, const AccessToken &token) diff --git a/plugins/experimental/access_control/access_control.h b/plugins/experimental/access_control/access_control.h index cd3f81e1b11..ee128a9c106 100644 --- a/plugins/experimental/access_control/access_control.h +++ b/plugins/experimental/access_control/access_control.h @@ -107,6 +107,17 @@ enum AccessTokenStatus { const char *accessTokenStatusToString(const AccessTokenStatus &state); +/** + * Validates whether a request path fails within the scope claim of an access token. + * Matching is performed on normalized path segments. An empty or absent scope is + * treated as unrestricted (returns true). + * + * @param[in] requestPath The incoming HTTP requestPath. + * @param[in] scope The scope string extracted from the token. + * @return True if the path is permitted by the scope, false otherwise. + */ +bool validateScope(StringView requestPath, StringView scope); + /** * Base Access Token class / interface + some basic implementations. */ @@ -202,7 +213,7 @@ class AccessToken StringView _issuedAt = ""; /** @brief time-stamp when token was issued, not required */ StringView _tokenId = ""; /** @brief unique token id for debugging and tracking, not required */ StringView _version = ""; /** @brief version, not required, still @todo */ - StringView _scope = ""; /** @brief scope of subject, not required, still @todo */ + StringView _scope = ""; /** @brief scope of subject, not required */ /** Signature, extracted from the token string */ StringView _keyId = ""; /** @brief the key in the secrets map to be used to calculate the digest */ From 6198e2c48b1363aed23517d5c9565cf5287e780b Mon Sep 17 00:00:00 2001 From: Mradul Pal Date: Sat, 19 Sep 2026 14:03:22 +0530 Subject: [PATCH 02/12] tests: Add scope validation test cases Add Catch2 unit tests for validateScope() --- .../unit_tests/test_access_control.cc | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/plugins/experimental/access_control/unit_tests/test_access_control.cc b/plugins/experimental/access_control/unit_tests/test_access_control.cc index 45c6fd0726f..620dd984506 100644 --- a/plugins/experimental/access_control/unit_tests/test_access_control.cc +++ b/plugins/experimental/access_control/unit_tests/test_access_control.cc @@ -170,3 +170,75 @@ TEST_CASE("AssetToken: simple HMAC SHA256 signature test", "[AssetToken][access_ CHECK(INVALID_SIGNATURE == token.validateSignature()); // DEBUG_OUT("Dumping token" << std::endl << token); } + +TEST_CASE("AccessToken: scope validation", "[AccessToken][access_control][scope]") +{ + SECTION("empty scope allows any request path") + { + CHECK(validateScope("/reports/2026/", "") == true); + CHECK(validateScope("/any/path/", "") == true); + CHECK(validateScope("/", "") == true); + } + + SECTION("exact match") + { + CHECK(validateScope("/reports", "/reports") == true); + CHECK(validateScope("/reports/", "/reports/") == true); + CHECK(validateScope("/reports", "/reports/") == true); + CHECK(validateScope("/reports/", "/reports") == true); + } + + SECTION("valid subpath matching") + { + CHECK(validateScope("/reports/2026/", "/reports/") == true); + CHECK(validateScope("/reports/2026/annual.pdf", "/reports") == true); + CHECK(validateScope("/api/v1/users/123", "/api/v1/users") == true); + } + + SECTION("segment boundary enforcement") + { + CHECK(validateScope("/reports2/", "/reports/") == false); + CHECK(validateScope("/reports2", "/reports") == false); + CHECK(validateScope("/reports_backup/2026", "/reports") == false); + CHECK(validateScope("/api/v1/users_admin", "/api/v1/users") == false); + } + + SECTION("mismatched paths") + { + CHECK(validateScope("/other/", "/reports/") == false); + CHECK(validateScope("/other/path", "/reports") == false); + CHECK(validateScope("/reports", "/reports/2026") == false); + CHECK(validateScope("/reports/", "/reports/2026/") == false); + } + + SECTION("normalization and edge cases") + { + CHECK(validateScope("/anything", "/") == true); + CHECK(validateScope("/", "/") == true); + CHECK(validateScope("reports/2026/", "/reports/") == true); + CHECK(validateScope("reports/2026/", "reports/") == true); + CHECK(validateScope("//reports///2026//", "/reports") == true); + } +} + +TEST_CASE("AccessToken: token scope claim integration", "[AccessToken][access_control][scope]") +{ + KvpAccessTokenConfig tokenConfig; + + KvpAccessTokenBuilder atb(tokenConfig, secrets); + atb.addSubject("FinanceUser"); + atb.addExpiration(1234567); + atb.addNotBefore(2345678); + atb.addIssuedAt(3456789); + atb.addTokenId("tokenidvalue"); + atb.addVersion("1"); + atb.addScope("/finance/"); + atb.sign("1", WDN_HASH_SHA256); + + KvpAccessToken token(tokenConfig, secrets, enableDebug); + CHECK(VALID == token.parse(atb.get())); + CHECK(token.getScope() == "/finance/"); + + CHECK(validateScope("/finance/q4_report.pdf", token.getScope()) == true); + CHECK(validateScope("/hr/payroll", token.getScope()) == false); +} From eba1cde75c44ad6080cbe3f629d08314599b5a25 Mon Sep 17 00:00:00 2001 From: Mradul Pal Date: Sat, 19 Sep 2026 14:37:16 +0530 Subject: [PATCH 03/12] scope-validation: Integrate scope check into enforceAccessControl() --- plugins/experimental/access_control/plugin.cc | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/plugins/experimental/access_control/plugin.cc b/plugins/experimental/access_control/plugin.cc index 53defce8f8a..240c3aef3c7 100644 --- a/plugins/experimental/access_control/plugin.cc +++ b/plugins/experimental/access_control/plugin.cc @@ -529,12 +529,22 @@ enforceAccessControl(TSHttpTxn txnp, TSRemapRequestInfo *rri, AccessControlConfi remapStatus = handleInvalidToken(txnp, data, reject, accessTokenStateToHttpStatus(data->_vaState, config), data->_vaState); } else { - /* Valid token, if configured extract the token subject to a header, - * only if we can trust it - token is valid to prevent using it by mistake */ - if (!config->_extrSubHdrName.empty()) { - String sub(token->getSubject()); - setHeader(rri->requestBufp, rri->requestHdrp, config->_extrSubHdrName.c_str(), config->_extrSubHdrName.size(), - sub.c_str(), sub.size()); + int pathLen = 0; + const char *path = TSUrlPathGet(rri->requestBufp, rri->requestUrl, &pathLen); + StringView reqPath(path ? path : "", pathLen); + + if (!validateScope(reqPath, token->getScope())) { + data->_vaState = OUT_OF_SCOPE; + remapStatus = + handleInvalidToken(txnp, data, reject, accessTokenStateToHttpStatus(data->_vaState, config), data->_vaState); + } else { + /* Valid token and in-scope, if configured extract the token subject to a header, + * only if we can trust it - token is valid to prevent using it by mistake */ + if (!config->_extrSubHdrName.empty()) { + String sub(token->getSubject()); + setHeader(rri->requestBufp, rri->requestHdrp, config->_extrSubHdrName.c_str(), config->_extrSubHdrName.size(), + sub.c_str(), sub.size()); + } } } /* If configure extract the UA token id into a header likely for debugging, From 82a94ad520279e87321b5c6eb62c71d6e38eeee5 Mon Sep 17 00:00:00 2001 From: Mradul Pal Date: Sat, 19 Sep 2026 14:39:46 +0530 Subject: [PATCH 04/12] docs: Document scope claim behaviour and matching semantics --- doc/admin-guide/plugins/access_control.en.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/admin-guide/plugins/access_control.en.rst b/doc/admin-guide/plugins/access_control.en.rst index 89c25818fc7..72c06e155ff 100644 --- a/doc/admin-guide/plugins/access_control.en.rst +++ b/doc/admin-guide/plugins/access_control.en.rst @@ -237,7 +237,7 @@ Query-Param-Style Named Claim format * ``iat`` for `issued at time`_, `optional` * ``tid`` for `token id`_, `optional` * ``ver`` for `version`_, `optional`, defaults to ``ver=1`` if not specified. - * ``scope`` for `scope`_, `optional`, ignored by the current version of the plugin, still not finalized (more applications and their use cases need to be studied to finalize the format) + * ``scope`` for `scope`_, `optional`, A path-prefix scope that restricts token use to matching request paths. Matching is performed on normalized path segments; * ``kid`` for `key id`_, `required` (tokens to be always signed) * ``st`` for `signature type`_, `optional` (default would be ``SHA-256`` if not specified) * ``md`` for `message digest`_ - this claim is `required` and expected to be always the last claim. From 5bdf062a00703c521fd21bf5f74120654e3a5a80 Mon Sep 17 00:00:00 2001 From: Ned Date: Mon, 21 Sep 2026 16:58:47 +0530 Subject: [PATCH 05/12] Update scope description for access control Clarify scope usage in access control documentation. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/admin-guide/plugins/access_control.en.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/admin-guide/plugins/access_control.en.rst b/doc/admin-guide/plugins/access_control.en.rst index 72c06e155ff..ff3300e5edb 100644 --- a/doc/admin-guide/plugins/access_control.en.rst +++ b/doc/admin-guide/plugins/access_control.en.rst @@ -237,7 +237,7 @@ Query-Param-Style Named Claim format * ``iat`` for `issued at time`_, `optional` * ``tid`` for `token id`_, `optional` * ``ver`` for `version`_, `optional`, defaults to ``ver=1`` if not specified. - * ``scope`` for `scope`_, `optional`, A path-prefix scope that restricts token use to matching request paths. Matching is performed on normalized path segments; + * ``scope`` for `scope`_, `optional`, An absent or empty scope is unrestricted. Otherwise, it is a path-prefix restriction matched against normalized path segments, including the segment boundary (for example, ``/reports`` matches ``/reports/2026`` but not ``/reports2``); out-of-scope requests use the configured ``--invalid-scope-status-code``. * ``kid`` for `key id`_, `required` (tokens to be always signed) * ``st`` for `signature type`_, `optional` (default would be ``SHA-256`` if not specified) * ``md`` for `message digest`_ - this claim is `required` and expected to be always the last claim. From 8c0fddf5b511f342599c1f6d9aa79ecc1fbdd004 Mon Sep 17 00:00:00 2001 From: Mradul Pal Date: Mon, 21 Sep 2026 20:22:11 +0530 Subject: [PATCH 06/12] scope-validation: Canonicalize dot segments and escaped traversal --- .../access_control/access_control.cc | 55 +++++++++++++++---- .../unit_tests/test_access_control.cc | 13 +++++ 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/plugins/experimental/access_control/access_control.cc b/plugins/experimental/access_control/access_control.cc index 080ced695f2..0bdee4e9bb3 100644 --- a/plugins/experimental/access_control/access_control.cc +++ b/plugins/experimental/access_control/access_control.cc @@ -29,24 +29,57 @@ size_t calcMessageDigest(const StringView hf, const char *secret, const char *message, size_t messageLen, char *buffer, size_t len); const char *getSecretMap(const StringMap &map, const StringView &key, size_t &secretSize); +static String +decodedDotSegment(StringView seg) +{ + String res; + res.reserve(seg.size()); + for (size_t i = 0; i < seg.size();) { + if (i + 2 < seg.size() && seg[i] == '%' && seg[i + 1] == '2' && (seg[i + 2] == 'e' || seg[i + 2] == 'E')) { + res.push_back('.'); + i += 3; + } else { + res.push_back(seg[i]); + ++i; + } + } + return res; +} + static String normalizePath(StringView path) { - String normalized; - normalized.reserve(path.size() + 1); + StringVector segments; + size_t start = 0; - normalized.push_back('/'); - for (char ch : path) { - if (ch == '/') { - if (normalized.back() != '/') { - normalized.push_back('/'); + while (start < path.size()) { + size_t end = path.find('/', start); + if (end == StringView::npos) { + end = path.size(); + } + if (end > start) { + StringView rawSeg = path.substr(start, end - start); + String seg = decodedDotSegment(rawSeg); + if (seg == ".") { + } else if (seg == "..") { + if (!segments.empty()) { + segments.pop_back(); + } + } else { + segments.push_back(std::move(seg)); } - } else { - normalized.push_back(ch); } + start = end + 1; } - if (normalized.size() > 1 && normalized.back() == '/') { - normalized.pop_back(); + + if (segments.empty()) { + return "/"; + } + + String normalized; + for (const auto &seg : segments) { + normalized.push_back('/'); + normalized.append(seg); } return normalized; } diff --git a/plugins/experimental/access_control/unit_tests/test_access_control.cc b/plugins/experimental/access_control/unit_tests/test_access_control.cc index 620dd984506..2fff28fd1a5 100644 --- a/plugins/experimental/access_control/unit_tests/test_access_control.cc +++ b/plugins/experimental/access_control/unit_tests/test_access_control.cc @@ -219,6 +219,19 @@ TEST_CASE("AccessToken: scope validation", "[AccessToken][access_control][scope] CHECK(validateScope("reports/2026/", "reports/") == true); CHECK(validateScope("//reports///2026//", "/reports") == true); } + + SECTION("path traversal security") + { + CHECK(validateScope("/reports/../hr/payroll", "/reports/") == false); + CHECK(validateScope("/reports/../../etc/passwd", "/reports/") == false); + CHECK(validateScope("/reports/%2e%2e/hr/payroll", "/reports/") == false); + CHECK(validateScope("/reports/%2E%2E/hr/payroll", "/reports/") == false); + CHECK(validateScope("/reports/.%2e/hr/payroll", "/reports/") == false); + CHECK(validateScope("/reports/%2e./hr/payroll", "/reports/") == false); + CHECK(validateScope("/reports/./2026/", "/reports/") == true); + CHECK(validateScope("/reports/2026/../2026/annual.pdf", "/reports") == true); + CHECK(validateScope("/reports/%2e/2026/", "/reports/") == true); + } } TEST_CASE("AccessToken: token scope claim integration", "[AccessToken][access_control][scope]") From 275fb1f59f35b6e79ccac96ca0a5e0f9c4d39c01 Mon Sep 17 00:00:00 2001 From: Mradul Pal Date: Mon, 21 Sep 2026 21:50:32 +0530 Subject: [PATCH 07/12] scope-validation: Canonicalize path separators and dot segments --- .../access_control/access_control.cc | 35 +++++++------------ .../unit_tests/test_access_control.cc | 7 ++++ 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/plugins/experimental/access_control/access_control.cc b/plugins/experimental/access_control/access_control.cc index 0bdee4e9bb3..7a68f2d184f 100644 --- a/plugins/experimental/access_control/access_control.cc +++ b/plugins/experimental/access_control/access_control.cc @@ -30,43 +30,32 @@ size_t calcMessageDigest(const StringView hf, const char *secret, const char *me const char *getSecretMap(const StringMap &map, const StringView &key, size_t &secretSize); static String -decodedDotSegment(StringView seg) +normalizePath(StringView path) { - String res; - res.reserve(seg.size()); - for (size_t i = 0; i < seg.size();) { - if (i + 2 < seg.size() && seg[i] == '%' && seg[i + 1] == '2' && (seg[i + 2] == 'e' || seg[i + 2] == 'E')) { - res.push_back('.'); - i += 3; - } else { - res.push_back(seg[i]); - ++i; - } + if (path.empty()) { + return "/"; } - return res; -} -static String -normalizePath(StringView path) -{ + String decoded(path.size(), '\0'); + size_t decodedLen = urlDecode(path.data(), path.size(), decoded.data(), decoded.size()); + decoded.resize(decodedLen); StringVector segments; size_t start = 0; - while (start < path.size()) { - size_t end = path.find('/', start); - if (end == StringView::npos) { - end = path.size(); + while (start < decoded.size()) { + size_t end = decoded.find_first_of("/\\", start); + if (end == String::npos) { + end = decoded.size(); } if (end > start) { - StringView rawSeg = path.substr(start, end - start); - String seg = decodedDotSegment(rawSeg); + StringView seg(decoded.data() + start, end - start); if (seg == ".") { } else if (seg == "..") { if (!segments.empty()) { segments.pop_back(); } } else { - segments.push_back(std::move(seg)); + segments.emplace_back(seg); } } start = end + 1; diff --git a/plugins/experimental/access_control/unit_tests/test_access_control.cc b/plugins/experimental/access_control/unit_tests/test_access_control.cc index 2fff28fd1a5..a68aacdc759 100644 --- a/plugins/experimental/access_control/unit_tests/test_access_control.cc +++ b/plugins/experimental/access_control/unit_tests/test_access_control.cc @@ -231,6 +231,13 @@ TEST_CASE("AccessToken: scope validation", "[AccessToken][access_control][scope] CHECK(validateScope("/reports/./2026/", "/reports/") == true); CHECK(validateScope("/reports/2026/../2026/annual.pdf", "/reports") == true); CHECK(validateScope("/reports/%2e/2026/", "/reports/") == true); + CHECK(validateScope("/reports/%2e%2e%2fhr/payroll", "/reports/") == false); + CHECK(validateScope("/reports/%2e%2e%2Fhr/payroll", "/reports/") == false); + CHECK(validateScope("/reports/..%2fhr/payroll", "/reports/") == false); + CHECK(validateScope("/reports/..%2Fhr/payroll", "/reports/") == false); + CHECK(validateScope("/reports/%2e%2e%5chr/payroll", "/reports/") == false); + CHECK(validateScope("/reports/%2e%2e%5Chr/payroll", "/reports/") == false); + CHECK(validateScope("/reports/..\\hr/payroll", "/reports/") == false); } } From d203a81aec6d6ef8c008d11f10fb2d688f033368 Mon Sep 17 00:00:00 2001 From: Ned Date: Tue, 22 Sep 2026 07:59:22 +0530 Subject: [PATCH 08/12] Refactor access control path comparison logic Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- plugins/experimental/access_control/access_control.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/experimental/access_control/access_control.cc b/plugins/experimental/access_control/access_control.cc index 7a68f2d184f..ca409797261 100644 --- a/plugins/experimental/access_control/access_control.cc +++ b/plugins/experimental/access_control/access_control.cc @@ -530,7 +530,7 @@ validateScope(StringView requestPath, StringView scope) if (normRequestPath == normScope) { return true; } - if (normRequestPath.starts_with(normScope) && normRequestPath[normScope.length()] == '/') { + if (normRequestPath.compare(0, normScope.length(), normScope) == 0 && normRequestPath[normScope.length()] == '/') { return true; } return false; From 62626b9eabf10c5f6723e88d5c54846d593019eb Mon Sep 17 00:00:00 2001 From: Mradul Pal Date: Tue, 22 Sep 2026 23:17:19 +0530 Subject: [PATCH 09/12] scope-validation: percent-decoding and reject traversal in scope --- .../access_control/access_control.cc | 61 +++++++++++++++---- .../unit_tests/test_access_control.cc | 27 ++------ 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/plugins/experimental/access_control/access_control.cc b/plugins/experimental/access_control/access_control.cc index ca409797261..8bce6189bcf 100644 --- a/plugins/experimental/access_control/access_control.cc +++ b/plugins/experimental/access_control/access_control.cc @@ -23,22 +23,48 @@ #include #include +#include #include "access_control.h" size_t calcMessageDigest(const StringView hf, const char *secret, const char *message, size_t messageLen, char *buffer, size_t len); const char *getSecretMap(const StringMap &map, const StringView &key, size_t &secretSize); -static String -normalizePath(StringView path) +static bool +percentDecodePath(StringView in, String &out) { + out.clear(); + out.reserve(in.size()); + for (size_t i = 0; i < in.size();) { + if (in[i] == '%') { + unsigned char val = 0; + if (i + 2 < in.size() && std::from_chars(in.data() + i + 1, in.data() + i + 3, val, 16).ec == std::errc{}) { + out.push_back(static_cast(val)); + i += 3; + } else { + return false; + } + } else { + out.push_back(in[i]); + ++i; + } + } + return true; +} + +static bool +normalizePath(StringView path, String &normalized, bool isScope = false) +{ + normalized.clear(); if (path.empty()) { - return "/"; + normalized = "/"; + return true; } - String decoded(path.size(), '\0'); - size_t decodedLen = urlDecode(path.data(), path.size(), decoded.data(), decoded.size()); - decoded.resize(decodedLen); + String decoded; + if (!percentDecodePath(path, decoded)) { + return false; + } StringVector segments; size_t start = 0; @@ -51,6 +77,9 @@ normalizePath(StringView path) StringView seg(decoded.data() + start, end - start); if (seg == ".") { } else if (seg == "..") { + if (isScope) { + return false; + } if (!segments.empty()) { segments.pop_back(); } @@ -60,17 +89,16 @@ normalizePath(StringView path) } start = end + 1; } - if (segments.empty()) { - return "/"; + normalized = "/"; + return true; } - String normalized; for (const auto &seg : segments) { normalized.push_back('/'); normalized.append(seg); } - return normalized; + return true; } /* AccessToken ***************************************************************************************************** */ @@ -522,15 +550,22 @@ validateScope(StringView requestPath, StringView scope) if (scope.empty()) { return true; } - String normRequestPath = normalizePath(requestPath); - String normScope = normalizePath(scope); + String normScope; + if (!normalizePath(scope, normScope, /* isScope = */ true)) { + return false; + } + String normRequestPath; + if (!normalizePath(requestPath, normRequestPath, /* isScope = */ false)) { + return false; + } + if (normScope == "/") { return true; } if (normRequestPath == normScope) { return true; } - if (normRequestPath.compare(0, normScope.length(), normScope) == 0 && normRequestPath[normScope.length()] == '/') { + if (normRequestPath.compare(0, normScope.size(), normScope) == 0 && normRequestPath[normScope.length()] == '/') { return true; } return false; diff --git a/plugins/experimental/access_control/unit_tests/test_access_control.cc b/plugins/experimental/access_control/unit_tests/test_access_control.cc index a68aacdc759..56d4f0288a3 100644 --- a/plugins/experimental/access_control/unit_tests/test_access_control.cc +++ b/plugins/experimental/access_control/unit_tests/test_access_control.cc @@ -180,12 +180,11 @@ TEST_CASE("AccessToken: scope validation", "[AccessToken][access_control][scope] CHECK(validateScope("/", "") == true); } - SECTION("exact match") + SECTION("exact match and trailing slashes") { CHECK(validateScope("/reports", "/reports") == true); - CHECK(validateScope("/reports/", "/reports/") == true); - CHECK(validateScope("/reports", "/reports/") == true); CHECK(validateScope("/reports/", "/reports") == true); + CHECK(validateScope("/reports", "/reports/") == true); } SECTION("valid subpath matching") @@ -198,46 +197,30 @@ TEST_CASE("AccessToken: scope validation", "[AccessToken][access_control][scope] SECTION("segment boundary enforcement") { CHECK(validateScope("/reports2/", "/reports/") == false); - CHECK(validateScope("/reports2", "/reports") == false); - CHECK(validateScope("/reports_backup/2026", "/reports") == false); CHECK(validateScope("/api/v1/users_admin", "/api/v1/users") == false); } SECTION("mismatched paths") { CHECK(validateScope("/other/", "/reports/") == false); - CHECK(validateScope("/other/path", "/reports") == false); CHECK(validateScope("/reports", "/reports/2026") == false); - CHECK(validateScope("/reports/", "/reports/2026/") == false); } SECTION("normalization and edge cases") { CHECK(validateScope("/anything", "/") == true); - CHECK(validateScope("/", "/") == true); CHECK(validateScope("reports/2026/", "/reports/") == true); - CHECK(validateScope("reports/2026/", "reports/") == true); CHECK(validateScope("//reports///2026//", "/reports") == true); } SECTION("path traversal security") { CHECK(validateScope("/reports/../hr/payroll", "/reports/") == false); - CHECK(validateScope("/reports/../../etc/passwd", "/reports/") == false); - CHECK(validateScope("/reports/%2e%2e/hr/payroll", "/reports/") == false); - CHECK(validateScope("/reports/%2E%2E/hr/payroll", "/reports/") == false); - CHECK(validateScope("/reports/.%2e/hr/payroll", "/reports/") == false); - CHECK(validateScope("/reports/%2e./hr/payroll", "/reports/") == false); - CHECK(validateScope("/reports/./2026/", "/reports/") == true); - CHECK(validateScope("/reports/2026/../2026/annual.pdf", "/reports") == true); - CHECK(validateScope("/reports/%2e/2026/", "/reports/") == true); CHECK(validateScope("/reports/%2e%2e%2fhr/payroll", "/reports/") == false); - CHECK(validateScope("/reports/%2e%2e%2Fhr/payroll", "/reports/") == false); - CHECK(validateScope("/reports/..%2fhr/payroll", "/reports/") == false); - CHECK(validateScope("/reports/..%2Fhr/payroll", "/reports/") == false); - CHECK(validateScope("/reports/%2e%2e%5chr/payroll", "/reports/") == false); - CHECK(validateScope("/reports/%2e%2e%5Chr/payroll", "/reports/") == false); CHECK(validateScope("/reports/..\\hr/payroll", "/reports/") == false); + CHECK(validateScope("/hr/payroll", "/reports/..") == false); + CHECK(validateScope("/reports/%", "/reports/") == false); + CHECK(validateScope("/reports+archive", "/reports+archive") == true); } } From 1abf0a3d0ca70d2daf29a142835da00597863b2e Mon Sep 17 00:00:00 2001 From: Ned Date: Wed, 23 Sep 2026 00:08:05 +0530 Subject: [PATCH 10/12] Update 'scope' parameter description in admin guide Clarified the description of the 'scope' parameter in the access control documentation. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/admin-guide/plugins/access_control.en.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/admin-guide/plugins/access_control.en.rst b/doc/admin-guide/plugins/access_control.en.rst index ff3300e5edb..d75c96e0325 100644 --- a/doc/admin-guide/plugins/access_control.en.rst +++ b/doc/admin-guide/plugins/access_control.en.rst @@ -237,7 +237,7 @@ Query-Param-Style Named Claim format * ``iat`` for `issued at time`_, `optional` * ``tid`` for `token id`_, `optional` * ``ver`` for `version`_, `optional`, defaults to ``ver=1`` if not specified. - * ``scope`` for `scope`_, `optional`, An absent or empty scope is unrestricted. Otherwise, it is a path-prefix restriction matched against normalized path segments, including the segment boundary (for example, ``/reports`` matches ``/reports/2026`` but not ``/reports2``); out-of-scope requests use the configured ``--invalid-scope-status-code``. + * ``scope`` for `scope`_, `optional`. an absent or empty scope is unrestricted. Otherwise, it is a path-prefix restriction matched against normalized path segments, including the segment boundary (for example, ``/reports`` matches ``/reports/2026`` but not ``/reports2``); out-of-scope requests use the configured ``--invalid-scope-status-code``. * ``kid`` for `key id`_, `required` (tokens to be always signed) * ``st`` for `signature type`_, `optional` (default would be ``SHA-256`` if not specified) * ``md`` for `message digest`_ - this claim is `required` and expected to be always the last claim. From 16b01e3edc9fcd2965ce47a65a6082ef500bd547 Mon Sep 17 00:00:00 2001 From: Ned Date: Wed, 23 Sep 2026 00:41:56 +0530 Subject: [PATCH 11/12] Fix documentation wording for access token validation Corrected the wording in the validation function's documentation. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- plugins/experimental/access_control/access_control.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/experimental/access_control/access_control.h b/plugins/experimental/access_control/access_control.h index ee128a9c106..ee4878616e1 100644 --- a/plugins/experimental/access_control/access_control.h +++ b/plugins/experimental/access_control/access_control.h @@ -108,7 +108,7 @@ enum AccessTokenStatus { const char *accessTokenStatusToString(const AccessTokenStatus &state); /** - * Validates whether a request path fails within the scope claim of an access token. + * Validates whether a request path falls within the scope claim of an access token. * Matching is performed on normalized path segments. An empty or absent scope is * treated as unrestricted (returns true). * From 2810622b55a843fdeafed6baa184b4c04c8bc624 Mon Sep 17 00:00:00 2001 From: Ned Date: Wed, 23 Sep 2026 13:25:36 +0530 Subject: [PATCH 12/12] Handle null path case in access control plugin Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- plugins/experimental/access_control/plugin.cc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/experimental/access_control/plugin.cc b/plugins/experimental/access_control/plugin.cc index 240c3aef3c7..76a97c65c2c 100644 --- a/plugins/experimental/access_control/plugin.cc +++ b/plugins/experimental/access_control/plugin.cc @@ -531,7 +531,10 @@ enforceAccessControl(TSHttpTxn txnp, TSRemapRequestInfo *rri, AccessControlConfi } else { int pathLen = 0; const char *path = TSUrlPathGet(rri->requestBufp, rri->requestUrl, &pathLen); - StringView reqPath(path ? path : "", pathLen); + if (path == nullptr) { + pathLen = 0; + } + StringView reqPath(path ? path : "", pathLen); if (!validateScope(reqPath, token->getScope())) { data->_vaState = OUT_OF_SCOPE;