Skip to content
2 changes: 1 addition & 1 deletion doc/admin-guide/plugins/access_control.en.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`, 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.
Expand Down
70 changes: 68 additions & 2 deletions plugins/experimental/access_control/access_control.cc
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,50 @@
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)
{
if (path.empty()) {
return "/";
}

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 < decoded.size()) {
size_t end = decoded.find_first_of("/\\", start);
if (end == String::npos) {
end = decoded.size();
}
if (end > start) {
StringView seg(decoded.data() + start, end - start);
if (seg == ".") {
} else if (seg == "..") {
if (!segments.empty()) {
segments.pop_back();
}
} else {
segments.emplace_back(seg);
}
}
start = end + 1;
}

if (segments.empty()) {
return "/";
}

String normalized;
for (const auto &seg : segments) {
normalized.push_back('/');
normalized.append(seg);
}
return normalized;
}

/* AccessToken ***************************************************************************************************** */

AccessToken::AccessToken(const StringMap &secretsMap, bool enableDebug) : _debug(enableDebug), _secretsMap(secretsMap) {}
Expand Down Expand Up @@ -61,8 +105,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;
}

Expand Down Expand Up @@ -470,6 +513,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;
Comment on lines +525 to +528
}
if (normRequestPath == normScope) {
return true;
}
if (normRequestPath.compare(0, normScope.length(), normScope) == 0 && normRequestPath[normScope.length()] == '/') {
return true;
}
return false;
}

/* Debug dump of the token */
std::ostream &
operator<<(std::ostream &os, const AccessToken &token)
Expand Down
13 changes: 12 additions & 1 deletion plugins/experimental/access_control/access_control.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 */
Expand Down
22 changes: 16 additions & 6 deletions plugins/experimental/access_control/plugin.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,95 @@ 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);
}

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);
}
}

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);
}