Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ Fixes:
Enterprise Fixes:
- [data-manager] Fixed editing an event whose key contains `&` creating undeletable duplicate rows in the events table

Security Fixes:
- [core] A user is no longer treated as an administrator of an app when no permission is defined for them on it (already fixed in later versions, backported here)
- [core] Auth tokens are now granted explicit create/read/update/delete permissions per app and feature, enforced on every request as the intersection of the token's permissions and its owner's. A token can only be granted permissions that the credential creating it already holds. Permission to sign in to the dashboard is a separate property of the token, assigned by the server, instead of being inferred from the token's purpose. Token creation, listing and deletion require a full-permission credential. Tokens created before this change keep working under the previous app and endpoint restrictions

## Version 24.05.51

Fixes:
Expand Down
2 changes: 2 additions & 0 deletions api/parts/mgmt/mail.js
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ mail.sendTimeBanWarning = function(member, db) {
multi: false,
owner: member._id,
app: "",
//the mailed link logs the member back in, so this token carries login permission
can_login: true,
callback: function(err, token) {
mail.lookup(function(err2, host) {
localize.getProperties(member.lang, function(err3, properties) {
Expand Down
77 changes: 52 additions & 25 deletions api/utils/authorizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ const log = require('./log.js')('core:authorizer');
* @param {string} options.token - token to store, if not provided, will be generated
* @param {string} options.owner - id of the user who created this token
* @param {string} options.app - list of the apps for which token was created
* @param {string} options.endpoint - regexp of endpoint(any string - is used as substring,to mach exact ^{yourpath}$)
* @param {string} options.endpoint - regexp of endpoint(any string - is used as substring,to mach exact ^{yourpath}$). Deprecated as an authorization mechanism, kept for tokens created before the permission model.
* @param {object} [options.token_permission] - CRUD permissions granted to this token, in the same shape as member.permission ({_:{a,u}, c/r/u/d:{appId:{all, allowed}}}). When set, the token authorizes only the intersection of this object and its owner's own permissions. When omitted, the token inherits the owner's full permissions.
* @param {boolean} [options.can_login=false] - if true, the token may be redeemed for a dashboard session at /login/token. SERVER-ASSIGNED ONLY - see the note below.
* @param {string} options.tryReuse - if true - tries to find not expired token with same parameters. If not founds cretes new token. If found - updates token expiration time to new one and returns token.
* @param {bool} [options.temporary=false] - If logged in with temporary token. Doesn't kill other sessions on logout.
* @param {function} options.callback - function called when saving was completed or errored, providing error object as first param and token string as second
Expand All @@ -34,6 +36,13 @@ authorizer.save = function(options) {
options.purpose = options.purpose || "";
options.temporary = options.temporary || false; //If logged in with temporary token. Doesn't kill other sessions on logout

// Login capability is a property of the token, never of its purpose string. It is set only
// where the server itself establishes or propagates a session (setLoggedInVariables, the
// renderer, the ban-warning mail, OIDC login), and by /i/token/create only when the creating
// credential already holds it. Defaulting to false here means any caller that does not ask
// for it explicitly produces a token that cannot be redeemed at /login/token.
options.can_login = options.can_login === true;

if (options.endpoint !== "" && !Array.isArray(options.endpoint)) {
options.endpoint = [options.endpoint];
}
Expand All @@ -53,8 +62,40 @@ authorizer.save = function(options) {
}
else if (member) {
authorizer.clearExpiredTokens(options);
/**
* Build the token document to store. token_permission is only written when the
* token is actually scoped, so an unscoped token stays absent-means-unrestricted.
* @returns {object} document to insert into auth_tokens
*/
var buildTokenDoc = function() {
var doc = {
_id: options.token,
ttl: options.ttl,
ends: options.ttl + Math.round(Date.now() / 1000),
multi: options.multi,
owner: options.owner,
app: options.app,
endpoint: options.endpoint,
purpose: options.purpose,
temporary: options.temporary,
can_login: options.can_login
};
if (options.token_permission) {
doc.token_permission = options.token_permission;
}
return doc;
};
if (options.tryReuse === true) {
var rules = {"multi": options.multi, "endpoint": options.endpoint, "app": options.app, "owner": options.owner, "purpose": options.purpose};
var rules = {"multi": options.multi, "endpoint": options.endpoint, "app": options.app, "owner": options.owner, "purpose": options.purpose, "can_login": options.can_login};
// Never reuse a token that grants something different from what is being asked
// for: an identical-looking token with a wider (or narrower) permission set is a
// different credential.
if (options.token_permission) {
rules.token_permission = options.token_permission;
}
else {
rules.token_permission = {$exists: false};
}
if (options.purpose === "LoggedInAuth") {
//Login token, allow switching from expiring to not expiring(and other way around)
//If there is changes to session expiration - this will allow to treat those tokens as same token.
Expand All @@ -78,17 +119,7 @@ authorizer.save = function(options) {
options.callback(err_token, token.value._id);
}
else {
options.db.collection("auth_tokens").insert({
_id: options.token,
ttl: options.ttl,
ends: options.ttl + Math.round(Date.now() / 1000),
multi: options.multi,
owner: options.owner,
app: options.app,
endpoint: options.endpoint,
purpose: options.purpose,
temporary: options.temporary
}, function(err1) {
options.db.collection("auth_tokens").insert(buildTokenDoc(), function(err1) {
if (typeof options.callback === "function") {
options.callback(err1, options.token);
}
Expand All @@ -97,17 +128,7 @@ authorizer.save = function(options) {
});
}
else {
options.db.collection("auth_tokens").insert({
_id: options.token,
ttl: options.ttl,
ends: options.ttl + Math.round(Date.now() / 1000),
multi: options.multi,
owner: options.owner,
app: options.app,
endpoint: options.endpoint,
purpose: options.purpose,
temporary: options.temporary
}, function(err1) {
options.db.collection("auth_tokens").insert(buildTokenDoc(), function(err1) {
if (typeof options.callback === "function") {
options.callback(err1, options.token);
}
Expand Down Expand Up @@ -249,7 +270,13 @@ var verify_token = function(options, return_owner, return_data) {
if (Array.isArray(res.endpoint) && res.endpoint.length === 0) {
res.endpoint = "";
}
if (res.endpoint && res.endpoint !== "") {
// The endpoint regex is the pre-permission-model scoping mechanism. It only ever
// constrained which paths a token could call, never what the resolved member was
// allowed to do, so it is not an authorization boundary. Tokens issued under the
// permission model carry token_permission instead, which rights.js intersects with
// the owner's own permissions on every request. Legacy tokens (no token_permission)
// keep being matched exactly as before, so existing integrations are unaffected.
if (!res.token_permission && res.endpoint && res.endpoint !== "") {
//keep backwards compability
if (!Array.isArray(res.endpoint)) {
res.endpoint = [res.endpoint];
Expand Down
100 changes: 98 additions & 2 deletions api/utils/requestProcessor.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const Promise = require('bluebird');
const url = require('url');
const common = require('./common.js');
const countlyCommon = require('../lib/countly.common.js');
const { validateAppAdmin, validateUser, validateRead, validateUserForRead, validateUserForWrite, validateGlobalAdmin, dbUserHasAccessToCollection, validateUpdate, validateDelete, validateCreate, getAdminApps, getUserAppsForFeaturePermission } = require('./rights.js');
const { validateAppAdmin, validateUser, validateRead, validateUserForRead, validateUserForWrite, validateGlobalAdmin, dbUserHasAccessToCollection, validateUpdate, validateDelete, validateCreate, getAdminApps, getUserAppsForFeaturePermission, isPermissionSubset, getPermissionApps, isScopedCredential } = require('./rights.js');
const authorize = require('./authorizer.js');
const taskmanager = require('./taskmanager.js');
const plugins = require('../../plugins/pluginManager.js');
Expand Down Expand Up @@ -497,6 +497,8 @@ const processRequest = (params) => {
owner: params.member._id,
ttl: 300,
purpose: "LoginAuthToken",
//the headless renderer authenticates by redeeming this at /login/token
can_login: true,
callback: function(err2, token) {
if (err2) {
common.returnMessage(params, 400, 'Error creating token: ' + err2);
Expand Down Expand Up @@ -2615,6 +2617,12 @@ const processRequest = (params) => {
*/
case 'delete':
validateUser(() => {
//revoking the owner's other credentials is credential management, not
//something a token narrowed to a subset of the owner's access may do
if (isScopedCredential(params)) {
common.returnMessage(params, 403, "A restricted token cannot delete tokens");
return;
}
if (params.qstring.tokenid) {
common.db.collection("auth_tokens").remove({
"_id": params.qstring.tokenid,
Expand Down Expand Up @@ -2661,7 +2669,80 @@ const processRequest = (params) => {
*/
case 'create':
validateUser(params, () => {
let ttl, multi, endpoint, purpose, apps;
let ttl, multi, endpoint, purpose, apps, tokenPermission;

// The credential doing the creating, not its owner, is the ceiling for what
// may be granted. params.member is already that ceiling: rights.js bounds the
// member by the authenticating token's permissions before any handler runs, so
// for an api_key it is the full member, and for a scoped token it is exactly
// that token's authority. A token therefore cannot mint a child that reaches
// an app or feature it cannot reach itself, even though their owner can.
const creatorToken = params.token_data;
/**
* Whether a legacy app/endpoint scope value actually restricts anything.
* @param {string|Array} scope - the app or endpoint field of a token
* @returns {boolean} true if the token is restricted by it
*/
const isScopeRestricted = function(scope) {
return !(scope === undefined || scope === null || scope === "" || (Array.isArray(scope) && scope.length === 0));
};
// Tokens created before the permission model carry an app/endpoint regex scope
// instead of permissions. That scope is not carried into params.member, so
// there is nothing to bound such a token's grants by - refuse rather than
// issue a child that could be wider than its parent.
const creatorIsLegacyRestricted = !!creatorToken && !creatorToken.token_permission && (isScopeRestricted(creatorToken.app) || isScopeRestricted(creatorToken.endpoint));
if (creatorIsLegacyRestricted) {
common.returnMessage(params, 403, "A restricted token cannot create tokens");
return;
}

if (params.qstring.permission && params.qstring.permission !== "") {
if (typeof params.qstring.permission === "string") {
try {
tokenPermission = JSON.parse(params.qstring.permission);
}
catch (ex) {
common.returnMessage(params, 400, "Invalid permission object");
return;
}
}
else {
tokenPermission = params.qstring.permission;
}
if (typeof tokenPermission !== "object" || Array.isArray(tokenPermission)) {
common.returnMessage(params, 400, "Invalid permission object");
return;
}
// The endpoint regex is not an authorization boundary and is ignored for
// permission-scoped tokens. Reject rather than store an inert restriction
// that would read as if it were enforced.
if (params.qstring.endpoint || params.qstring.endpointquery) {
common.returnMessage(params, 400, "Endpoint restrictions cannot be combined with permissions");
return;
}
if (!isPermissionSubset(tokenPermission, params.member)) {
common.returnMessage(params, 403, "Token permissions must be a subset of the creating credential's permissions");
return;
}
}

// Login is a capability of the token, never of its purpose string, and it is
// only ever passed on by a credential that holds it, to a child that is not
// narrowed. That makes a scoped token unable to produce a session, which is
// what the purpose allowlist used to (and could not) guarantee.
const requestedLogin = params.qstring.can_login === true || params.qstring.can_login === "true" || params.qstring.can_login === "1";
let canLogin = false;
if (requestedLogin) {
//a token created before this model that carries no restriction at all is a
//full-permission credential, and keeps the login authority it has today
const creatorHasLogin = !creatorToken || creatorToken.can_login === true || (typeof creatorToken.can_login === "undefined" && !creatorToken.token_permission);
if (!creatorHasLogin || tokenPermission) {
common.returnMessage(params, 403, "The creating credential cannot grant login permission");
return;
}
canLogin = true;
}

if (params.qstring.ttl) {
ttl = parseInt(params.qstring.ttl);
}
Expand All @@ -2676,6 +2757,11 @@ const processRequest = (params) => {
if (params.qstring.apps) {
apps = params.qstring.apps.split(',');
}
//keep the app list in step with the granted permissions, so the token list
//shows what the token reaches and the app check stays a useful early filter
if (tokenPermission && !params.qstring.apps) {
apps = getPermissionApps(tokenPermission);
}

if (params.qstring.endpointquery && params.qstring.endpointquery !== "") {
try {
Expand Down Expand Up @@ -2705,6 +2791,8 @@ const processRequest = (params) => {
app: apps,
endpoint: endpoint,
purpose: purpose,
token_permission: tokenPermission,
can_login: canLogin,
callback: (err, token) => {
if (err) {
common.returnMessage(params, 404, err);
Expand Down Expand Up @@ -2794,6 +2882,14 @@ const processRequest = (params) => {
*/
case 'list':
validateUser(params, function() {
// Every document here includes its _id, which is the token itself. Listing
// them from a scoped token would hand it the owner's other credentials -
// including the unrestricted session token - and let it act as any of them,
// which is the escalation the permission model exists to prevent.
if (isScopedCredential(params)) {
common.returnMessage(params, 403, "A restricted token cannot list tokens");
return;
}
common.db.collection("auth_tokens").find({"owner": params.member._id + ""}).toArray(function(err, res) {
if (err) {
common.returnMessage(params, 404, err.message);
Expand Down
Loading
Loading