Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 78 additions & 45 deletions api/utils/requestProcessor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions frontend/express/libs/members.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}});
Expand Down
108 changes: 107 additions & 1 deletion test/2.api/16.token.manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -379,4 +379,110 @@ describe('Testing token manager', function() {
});
});
});
});

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