diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ace8a79ae..3fbc24f4a68 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] 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/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/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}}); 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(); + }); + }); + }); +});