From 4e2aec33d2d4c47f49e7da7206dd084f5a5a1276 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 19:49:44 +0300 Subject: [PATCH 1/2] security(core): allow token creation only from a full-permission credential A token's app/endpoint restriction scopes which app data it may read or write through data endpoints. It does not gate /i/token/create, which carries no app_id, so verify_token never compares the restriction there (and it is skipped entirely when no app_id is supplied). A token "restricted to App A" therefore had unrestricted access to token creation. validateUser resolves any bearer token to its owner and loads the full member, and the create handler then saved caller-supplied app/endpoint/purpose with no relationship to the authenticating credential. So a restricted token could mint an unrestricted child, or a LoggedInAuth token redeemable at /login/token/:token for a full dashboard session, escalating from an app-scoped integration token to the owner's entire account (global admin included, if the owner is one). Gate the create handler: when authenticated via a token, allow creation only if that token has no app restriction and no endpoint restriction. api_key callers (the member itself) and unrestricted tokens - which is what a dashboard session token is (multi, app "", endpoint "") - are unaffected, so the token manager and the create-a-login-token-and-redirect flow keep working. A restricted token is refused with 403. This is deliberately the conservative end of the planned "child permissions are a subset of the creating credential" model: reject rather than intersect, since the app/endpoint model does not sensibly authorize management endpoints. Tests: 7 cases in test/2.api/16.token.manager.js covering the restricted-token refusals (unrestricted child, LoggedInAuth child, refused even when it supplies its own app_id) and the api_key / full-permission-token allow cases. Reported through the security bug bounty programme (received 2026-08-18). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + api/utils/requestProcessor.js | 123 +++++++++++++++++++++------------ test/2.api/16.token.manager.js | 108 ++++++++++++++++++++++++++++- 3 files changed, 186 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ace8a79ae..1cfa5d2dad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Enterprise Fixes: - [data-manager] Fixed editing an event whose key contains `&` creating undeletable duplicate rows in the events table Security Fixes: +- [core] Token creation is now allowed only from a full-permission credential: an api_key, or a token with no app and no endpoint restriction. A restricted token can no longer create a token, which previously let it mint an unrestricted or login-capable token and escalate beyond its own scope - [hooks] Internal event hooks are now scoped to the apps the hook belongs to: app creation is a global-admin-only event, and remote-config, cohort, alert and hook-chaining events are only delivered when the event's app is one the hook is scoped to - [compliance-hub] The consents table now returns a fixed set of fields; a projection supplied on the request is no longer used to widen the response beyond the consent columns - [dashboards] Widgets are no longer copied when the copying user has no access to the apps they reference, and widget app ids are validated on widget create and update diff --git a/api/utils/requestProcessor.js b/api/utils/requestProcessor.js index 3287fe1c7dc..2f5cbdaf6b6 100644 --- a/api/utils/requestProcessor.js +++ b/api/utils/requestProcessor.js @@ -2564,58 +2564,91 @@ const processRequest = (params) => { */ case 'create': validateUser(params, () => { - let ttl, multi, endpoint, purpose, apps; - if (params.qstring.ttl) { - ttl = parseInt(params.qstring.ttl); - } - else { - ttl = 1800; - } - multi = true; - if (params.qstring.multi === false || params.qstring.multi === 'false') { - multi = false; - } - apps = params.qstring.apps || ""; - if (params.qstring.apps) { - apps = params.qstring.apps.split(','); - } - - if (params.qstring.endpointquery && params.qstring.endpointquery !== "") { - try { - endpoint = JSON.parse(params.qstring.endpointquery); //structure with also info for qstring params. + // Token creation is a privileged management operation. A token's app/endpoint + // restriction scopes which app data it may read or write through data endpoints; + // it does not gate this endpoint, which carries no app_id, so it cannot be relied + // on to constrain what is created here. A scoped token must therefore not be able + // to mint a token: it could create an unrestricted child, or a LoggedInAuth token + // redeemable at /login/token/:token for a full session, and so escalate beyond its + // own scope. Allow creation only from a full-permission credential: an api_key (the + // member itself) or a token with no app and no endpoint restriction, such as the + // dashboard session token. Future: enforce the child's permissions as a subset of + // the creating credential's CRUD permissions. + const creatorToken = params.qstring.auth_token || (params.req && params.req.headers && params.req.headers["countly-token"]) || ""; + /** + * Parse the request and save the new token. Reached only once the caller is + * allowed to create tokens. + * @returns {void} + */ + const proceedWithCreate = function() { + let ttl, multi, endpoint, purpose, apps; + if (params.qstring.ttl) { + ttl = parseInt(params.qstring.ttl); } - catch (ex) { - if (params.qstring.endpoint) { - endpoint = params.qstring.endpoint.split(','); + else { + ttl = 1800; + } + multi = true; + if (params.qstring.multi === false || params.qstring.multi === 'false') { + multi = false; + } + apps = params.qstring.apps || ""; + if (params.qstring.apps) { + apps = params.qstring.apps.split(','); + } + + if (params.qstring.endpointquery && params.qstring.endpointquery !== "") { + try { + endpoint = JSON.parse(params.qstring.endpointquery); //structure with also info for qstring params. } - else { - endpoint = ""; + catch (ex) { + if (params.qstring.endpoint) { + endpoint = params.qstring.endpoint.split(','); + } + else { + endpoint = ""; + } } } - } - else if (params.qstring.endpoint) { - endpoint = params.qstring.endpoint.split(','); - } + else if (params.qstring.endpoint) { + endpoint = params.qstring.endpoint.split(','); + } - if (params.qstring.purpose) { - purpose = params.qstring.purpose; - } - authorize.save({ - db: common.db, - ttl: ttl, - multi: multi, - owner: params.member._id + "", - app: apps, - endpoint: endpoint, - purpose: purpose, - callback: (err, token) => { - if (err) { - common.returnMessage(params, 404, err); - } - else { - common.returnMessage(params, 200, token); + if (params.qstring.purpose) { + purpose = params.qstring.purpose; + } + authorize.save({ + db: common.db, + ttl: ttl, + multi: multi, + owner: params.member._id + "", + app: apps, + endpoint: endpoint, + purpose: purpose, + callback: (err, token) => { + if (err) { + common.returnMessage(params, 404, err); + } + else { + common.returnMessage(params, 200, token); + } } + }); + }; + if (!creatorToken) { + //api_key authenticated: the member itself, full permission + proceedWithCreate(); + return; + } + common.db.collection("auth_tokens").findOne({_id: creatorToken + ""}, function(tokenErr, creatorTokenDoc) { + const isScopeRestricted = function(scope) { + return !(scope === undefined || scope === null || scope === "" || (Array.isArray(scope) && scope.length === 0)); + }; + if (tokenErr || !creatorTokenDoc || isScopeRestricted(creatorTokenDoc.app) || isScopeRestricted(creatorTokenDoc.endpoint)) { + common.returnMessage(params, 403, "A restricted token cannot create tokens"); + return; } + proceedWithCreate(); }); }); break; diff --git a/test/2.api/16.token.manager.js b/test/2.api/16.token.manager.js index eceac22a375..8d9a3781ada 100644 --- a/test/2.api/16.token.manager.js +++ b/test/2.api/16.token.manager.js @@ -379,4 +379,110 @@ describe('Testing token manager', function() { }); }); }); -}); \ No newline at end of file + + describe('Preventing scope escalation via token create', function() { + var restrictedToken = ""; + var fullPermissionToken = ""; + + it('setup: create an app-scoped restricted token via api_key', function(done) { + request + .get('/i/token/create?api_key=' + API_KEY_ADMIN + '&apps=' + APP_ID + '&purpose=integration&multi=true&ttl=3600') + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + var ob = JSON.parse(res.text); + ob.should.have.property('result'); + restrictedToken = ob.result; + (restrictedToken !== "").should.equal(true); + done(); + }); + }); + + it('a restricted token cannot create an unrestricted child (no app_id supplied)', function(done) { + request + .get('/i/token/create?auth_token=' + restrictedToken + '&multi=true&ttl=300') + .expect(403) + .end(function(err, res) { + if (err) { + return done(err); + } + var ob = JSON.parse(res.text); + ob.should.have.property('result', 'A restricted token cannot create tokens'); + done(); + }); + }); + + it('a restricted token cannot create a LoggedInAuth session child', function(done) { + request + .get('/i/token/create?auth_token=' + restrictedToken + '&purpose=LoggedInAuth&multi=true&ttl=300') + .expect(403) + .end(function(err, res) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('a restricted token is refused even when it supplies its own permitted app_id', function(done) { + request + .get('/i/token/create?auth_token=' + restrictedToken + '&app_id=' + APP_ID + '&apps=' + APP_ID + '&multi=true&ttl=300') + .expect(403) + .end(function(err, res) { + if (err) { + return done(err); + } + done(); + }); + }); + + it('api_key can still create a token', function(done) { + request + .get('/i/token/create?api_key=' + API_KEY_ADMIN + '&multi=true&ttl=300') + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + var ob = JSON.parse(res.text); + ob.should.have.property('result'); + testUtils.db.collection("auth_tokens").remove({_id: ob.result + ""}, function() { + done(); + }); + }); + }); + + it('a full-permission (unrestricted) token can create a token, as the dashboard session does', function(done) { + request + .get('/i/token/create?api_key=' + API_KEY_ADMIN + '&multi=true&ttl=300') + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + fullPermissionToken = JSON.parse(res.text).result; + request + .get('/i/token/create?auth_token=' + fullPermissionToken + '&purpose=child&multi=true&ttl=300') + .expect(200) + .end(function(err2, res2) { + if (err2) { + return done(err2); + } + var child = JSON.parse(res2.text).result; + (child !== "").should.equal(true); + testUtils.db.collection("auth_tokens").remove({_id: {$in: [fullPermissionToken + "", child + ""]}}, function() { + done(); + }); + }); + }); + }); + + it('cleanup: remove the restricted token', function(done) { + testUtils.db.collection("auth_tokens").remove({_id: restrictedToken + ""}, function() { + done(); + }); + }); + }); +}); From f993dcf7b866fb479b55740e5e01555d47bf5190 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 19:51:32 +0300 Subject: [PATCH 2/2] security(core): refuse dashboard login from a scoped token Defence in depth for the token scope-escalation chain. /login/token/:token gated only on the token's purpose, then granted the owner's full session regardless of the token's app/endpoint scope (and it carries no app_id, so verify_token's app check is skipped there too). So any scoped token that carried a login purpose could still be redeemed for a full session. Require the token to be unrestricted (no app and no endpoint scope) before establishing a session, in addition to the purpose allowlist. Legitimate session tokens are always created unrestricted (setLoggedInVariables, the renderer's LoginAuthToken, the ban-warning mail), so only scoped tokens are rejected. Together with the create-side gate, this enforces the invariant from both ends: a session grants the owner's full identity, so it may only come from a full-permission token. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- frontend/express/libs/members.js | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cfa5d2dad2..3fbc24f4a68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Enterprise Fixes: - [data-manager] Fixed editing an event whose key contains `&` creating undeletable duplicate rows in the events table Security Fixes: -- [core] Token creation is now allowed only from a full-permission credential: an api_key, or a token with no app and no endpoint restriction. A restricted token can no longer create a token, which previously let it mint an unrestricted or login-capable token and escalate beyond its own scope +- [core] A token restricted to specific apps or endpoints can no longer escalate its scope. Token creation is allowed only from a full-permission credential (an api_key, or a token with no app and no endpoint restriction), and a scoped token can no longer be redeemed for a dashboard session at /login/token. Previously a token scoped to one app could mint an unrestricted or login-capable token and take over the owner's full account - [hooks] Internal event hooks are now scoped to the apps the hook belongs to: app creation is a global-admin-only event, and remote-config, cohort, alert and hook-chaining events are only delivered when the event's app is one the hook is scoped to - [compliance-hub] The consents table now returns a fixed set of fields; a projection supplied on the request is no longer used to widen the response beyond the consent columns - [dashboards] Widgets are no longer copied when the copying user has no access to the apps they reference, and widget app ids are validated on widget create and update diff --git a/frontend/express/libs/members.js b/frontend/express/libs/members.js index 37cff1f3e4b..6f7aa204ff7 100644 --- a/frontend/express/libs/members.js +++ b/frontend/express/libs/members.js @@ -501,6 +501,21 @@ membersUtility.loginWithToken = function(req, callback) { return callback(undefined); } + // A session grants the owner's full identity, so it may only be established + // from a full-permission token. A token restricted to specific apps or + // endpoints must never be redeemable for a session: that would escalate its + // scope to the owner's entire account. Legitimate session tokens are always + // created unrestricted (setLoggedInVariables, the renderer, and the ban-warning + // mail all save with no app and no endpoint), so this rejects only scoped + // tokens that should never have reached a login in the first place. + var isLoginScopeRestricted = function(scope) { + return !(scope === undefined || scope === null || scope === "" || (Array.isArray(scope) && scope.length === 0)); + }; + if (isLoginScopeRestricted(valid.app) || isLoginScopeRestricted(valid.endpoint)) { + plugins.callMethod("tokenLoginFailed", {req: req, data: {token: token, token_owner: valid.owner, reason: "restricted_token"}}); + return callback(undefined); + } + membersUtility.db.collection('members').findOne({"_id": membersUtility.db.ObjectID(valid.owner)}, function(err, member) { if (err || !member) { plugins.callMethod("tokenLoginFailed", {req: req, data: {token: token, token_owner: valid.owner}});