diff --git a/dashboard/src/models/TokensModel.js b/dashboard/src/models/TokensModel.js
index 5bf9ec89c..891f6eaa8 100644
--- a/dashboard/src/models/TokensModel.js
+++ b/dashboard/src/models/TokensModel.js
@@ -18,10 +18,10 @@ function create() {
if (error || result.status !== 200) return [error || result];
return [null, result.body.tokens];
},
- async add(name, scope) {
+ async add(name, scope, allowedIpRanges) {
let error, result;
try {
- result = await fetcher.post(`${API_ORIGIN}/api/v1/tokens`, { name, scope }, { access_token: accessToken });
+ result = await fetcher.post(`${API_ORIGIN}/api/v1/tokens`, { name, scope, allowedIpRanges }, { access_token: accessToken });
} catch (e) {
error = e;
}
diff --git a/migrations/20250307100449-tokens-add-allowedIpRangesJson.js b/migrations/20250307100449-tokens-add-allowedIpRangesJson.js
new file mode 100644
index 000000000..796d5aa75
--- /dev/null
+++ b/migrations/20250307100449-tokens-add-allowedIpRangesJson.js
@@ -0,0 +1,9 @@
+'use strict';
+
+exports.up = async function (db) {
+ await db.runSql('ALTER TABLE tokens ADD COLUMN allowedIpRanges TEXT NULL');
+};
+
+exports.down = async function (db) {
+ await db.runSql('ALTER TABLE tokens DROP COLUMN allowedIpRanges');
+};
diff --git a/migrations/schema.sql b/migrations/schema.sql
index 1aa5ec61e..1a51a2df4 100644
--- a/migrations/schema.sql
+++ b/migrations/schema.sql
@@ -62,6 +62,7 @@ CREATE TABLE IF NOT EXISTS tokens(
scopeJson TEXT,
expires BIGINT NOT NULL, // FIXME: make this a timestamp
lastUsedTime TIMESTAMP NULL,
+ allowedIpRanges TEXT NULL,
PRIMARY KEY(accessToken));
CREATE TABLE IF NOT EXISTS apps(
diff --git a/src/oidc.js b/src/oidc.js
index c18a01187..20089b8eb 100644
--- a/src/oidc.js
+++ b/src/oidc.js
@@ -282,12 +282,9 @@ class CloudronAdapter {
if (this.name === 'Client') {
debug('upsert: this should not happen as it is stored in our db');
} else if (this.name === 'AccessToken' && (payload.clientId === tokens.ID_WEBADMIN || payload.clientId === tokens.ID_DEVELOPMENT)) {
- const clientId = payload.clientId;
- const identifier = payload.accountId;
const expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS;
- const accessToken = id;
- const [error] = await safe(tokens.add({ clientId, identifier, expires, accessToken }));
+ const [error] = await safe(tokens.add({ clientId: payload.clientId, identifier: payload.accountId, expires, accessToken: id, allowedIpRanges: '' }));
if (error) {
console.log('Error adding access token', error);
throw error;
diff --git a/src/provision.js b/src/provision.js
index 9ac7426f3..a077afafb 100644
--- a/src/provision.js
+++ b/src/provision.js
@@ -151,7 +151,7 @@ async function activate(username, password, email, displayName, ip, auditSource)
if (error && error.reason === BoxError.ALREADY_EXISTS) throw new BoxError(BoxError.CONFLICT, 'Already activated');
if (error) throw error;
- const token = { clientId: tokens.ID_WEBADMIN, identifier: ownerId, expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS };
+ const token = { clientId: tokens.ID_WEBADMIN, identifier: ownerId, allowedIpRanges: '', expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS };
const result = await tokens.add(token);
await eventlog.add(eventlog.ACTION_ACTIVATE, auditSource, {});
diff --git a/src/routes/accesscontrol.js b/src/routes/accesscontrol.js
index d25cef0ab..1f13513b8 100644
--- a/src/routes/accesscontrol.js
+++ b/src/routes/accesscontrol.js
@@ -59,10 +59,13 @@ async function tokenAuth(req, res, next) {
if (!token) return next(new HttpError(401, 'No such token'));
const user = await users.get(token.identifier);
- if (!user) return next(new HttpError(401,'User not found'));
- if (!user.active) return next(new HttpError(401,'User not active'));
+ if (!user) return next(new HttpError(401, 'User not found'));
+ if (!user.active) return next(new HttpError(401, 'User not active'));
- await safe(tokens.update(token.id, { lastUsedTime: new Date() })); // ignore any error
+ const remoteAddress = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
+ if (!tokens.isIpAllowedSync(token, remoteAddress)) return next(new HttpError(401, 'Token not allowed from this IP'));
+
+ await tokens.update(token.id, { lastUsedTime: new Date() });
req.token = token;
req.user = user;
diff --git a/src/routes/auth.js b/src/routes/auth.js
index e89cbc23d..cc8f1f6b4 100644
--- a/src/routes/auth.js
+++ b/src/routes/auth.js
@@ -36,7 +36,7 @@ async function login(req, res, next) {
const tokenTypeError = tokens.validateTokenType(type);
if (tokenTypeError) return next(new HttpError(400, tokenTypeError.message));
- const [error, token] = await safe(tokens.add({ clientId: type, identifier: req.user.id, expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS }));
+ const [error, token] = await safe(tokens.add({ clientId: type, identifier: req.user.id, allowedIpRanges: '', expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS }));
if (error) return next(new HttpError(500, error));
const auditSource = AuditSource.fromRequest(req);
@@ -90,7 +90,7 @@ async function passwordReset(req, res, next) {
if (error && error.reason === BoxError.BAD_FIELD) return next(new HttpError(400, error.message));
if (error) return next(BoxError.toHttpError(error));
- const [addError, result] = await safe(tokens.add({ clientId: tokens.ID_WEBADMIN, identifier: userObject.id, expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS }));
+ const [addError, result] = await safe(tokens.add({ clientId: tokens.ID_WEBADMIN, identifier: userObject.id, allowedIpRanges: '', expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS }));
if (addError) return next(BoxError.toHttpError(addError));
next(new HttpSuccess(202, { accessToken: result.accessToken }));
@@ -125,5 +125,4 @@ async function getBranding(req, res, next) {
};
next(new HttpSuccess(200, result));
-
}
\ No newline at end of file
diff --git a/src/routes/test/api-test.js b/src/routes/test/api-test.js
index 5e3fa1a05..d37bded64 100644
--- a/src/routes/test/api-test.js
+++ b/src/routes/test/api-test.js
@@ -82,7 +82,8 @@ describe('API', function () {
identifier: owner.id,
clientId: 'clientid-2',
expires: Date.now() + 2000, // expires in 3 seconds
- lastUsedTime: null
+ lastUsedTime: null,
+ allowedIpRanges: '127.0.0.1'
};
const result = await tokens.add(token2);
diff --git a/src/routes/test/common.js b/src/routes/test/common.js
index aa92dd13f..a6d1c6cff 100644
--- a/src/routes/test/common.js
+++ b/src/routes/test/common.js
@@ -149,7 +149,7 @@ async function setup() {
expect(response.status).to.equal(201);
admin.id = response.body.id;
// HACK to get a token for second user (passwords are generated and the user should have gotten a password setup link...)
- const token1 = await tokens.add({ identifier: admin.id, clientId: tokens.ID_WEBADMIN, expires: Date.now() + (60 * 60 * 1000), name: 'fromtest' });
+ const token1 = await tokens.add({ identifier: admin.id, clientId: tokens.ID_WEBADMIN, expires: Date.now() + (60 * 60 * 1000), name: 'fromtest', allowedIpRanges: '' });
admin.token = token1.accessToken;
// create user
@@ -159,7 +159,7 @@ async function setup() {
expect(response.status).to.equal(201);
user.id = response.body.id;
// HACK to get a token for second user (passwords are generated and the user should have gotten a password setup link...)
- const token2 = await tokens.add({ identifier: user.id, clientId: tokens.ID_WEBADMIN, expires: Date.now() + (60 * 60 * 1000), name: 'fromtest' });
+ const token2 = await tokens.add({ identifier: user.id, clientId: tokens.ID_WEBADMIN, expires: Date.now() + (60 * 60 * 1000), name: 'fromtest', allowedIpRanges: '' });
user.token = token2.accessToken;
// create app object
diff --git a/src/routes/test/profile-test.js b/src/routes/test/profile-test.js
index 24062c32b..eb70c0c3b 100644
--- a/src/routes/test/profile-test.js
+++ b/src/routes/test/profile-test.js
@@ -60,7 +60,7 @@ describe('Profile API', function () {
});
it('fails with expired token', async function () {
- const token = await tokens.add({ identifier: '0', clientId: 'clientid-0', expires: Date.now() - 2000 });
+ const token = await tokens.add({ identifier: '0', clientId: 'clientid-0', expires: Date.now() - 2000, allowedIpRanges: '' });
expect(token.accessToken).to.be.a('string');
const response = await superagent.get(`${serverUrl}/api/v1/profile`)
diff --git a/src/routes/test/tokens-test.js b/src/routes/test/tokens-test.js
index fdd3b03c2..a92340828 100644
--- a/src/routes/test/tokens-test.js
+++ b/src/routes/test/tokens-test.js
@@ -79,6 +79,35 @@ describe('Tokens API', function () {
});
});
+ describe('allowedIpRanges', function () {
+ let allowedRangeToken;
+
+ it('cannot create token with bad range', async function () {
+ const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
+ .query({ access_token: owner.token })
+ .send({ name: 'mytoken1', allowedIpRanges: 'What' })
+ .ok(() => true);
+
+ expect(response.status).to.equal(400);
+ });
+
+ it('can create token with valid range', async function () {
+ const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
+ .query({ access_token: owner.token })
+ .send({ name: 'mytoken1', allowedIpRanges: '#this is localhost\n10.0.0.0/8' });
+
+ expect(response.status).to.equal(201);
+ allowedRangeToken = response.body;
+ });
+
+ it('cannot use access restricted token', async function () {
+ const response = await superagent.get(`${serverUrl}/api/v1/tokens`)
+ .query({ access_token: allowedRangeToken.accessToken })
+ .ok(() => true);
+ expect(response.status).to.equal(401);
+ });
+ });
+
describe('readonly token', function () {
it('cannot create token with read only token', async function () {
const response = await superagent.post(`${serverUrl}/api/v1/tokens`)
diff --git a/src/routes/tokens.js b/src/routes/tokens.js
index b9ab4faba..c5b75fd48 100644
--- a/src/routes/tokens.js
+++ b/src/routes/tokens.js
@@ -52,11 +52,14 @@ async function add(req, res, next) {
if (typeof req.body.name !== 'string') return next(new HttpError(400, 'name must be string'));
if ('expiresAt' in req.body && typeof req.body.expiresAt !== 'number') return next(new HttpError(400, 'expiresAt must be number'));
if ('scope' in req.body && typeof req.body.scope !== 'object') return next(new HttpError(400, 'scope must be an object'));
+ // this is a string to allow comments
+ if ('allowedIpRanges' in req.body && typeof req.body.allowedIpRanges !== 'string') return next(new HttpError(400, 'allowedIpRanges must be a string'));
const expiresAt = req.body.expiresAt || (Date.now() + (100 * 365 * 24 * 60 * 60 * 1000)); // forever - 100 years TODO maybe we should allow 0 or -1 to make that explicit
const scope = req.body.scope || null;
+ const allowedIpRanges = req.body.allowedIpRanges || '';
- const [error, result] = await safe(tokens.add({ clientId: tokens.ID_SDK, identifier: req.user.id, expires: expiresAt, name: req.body.name, scope }));
+ const [error, result] = await safe(tokens.add({ clientId: tokens.ID_SDK, identifier: req.user.id, expires: expiresAt, name: req.body.name, scope, allowedIpRanges }));
if (error) return next(BoxError.toHttpError(error));
next(new HttpSuccess(201, result));
diff --git a/src/test/janitor-test.js b/src/test/janitor-test.js
index f1478e33e..26bc99c71 100644
--- a/src/test/janitor-test.js
+++ b/src/test/janitor-test.js
@@ -23,7 +23,8 @@ describe('janitor', function () {
clientId: 'clientid-1',
expires: Number.MAX_SAFE_INTEGER,
lastUsedTime: null,
- scope: { '*': 'rw' }
+ scope: { '*': 'rw' },
+ allowedIpRanges: '#this'
};
const token2 = {
name: 'token2',
@@ -31,7 +32,8 @@ describe('janitor', function () {
clientId: 'clientid-2',
expires: Date.now(),
lastUsedTime: null,
- scope: null //{ '*': 'rw '}
+ scope: null, //{ '*': 'rw '}
+ allowedIpRanges: '1.2.3.5'
};
it('can cleanupTokens', async function () {
diff --git a/src/test/tokens-test.js b/src/test/tokens-test.js
index dab470a2f..34cb5c8b4 100644
--- a/src/test/tokens-test.js
+++ b/src/test/tokens-test.js
@@ -26,15 +26,10 @@ describe('Tokens', function () {
clientId: 'clientid-0',
expires: Date.now() + 60 * 60000,
lastUsedTime: null,
- scope: { '*': 'rw' }
+ scope: { '*': 'rw' },
+ allowedIpRanges: '#this is our server\n3.4.5.6\nfe80::42:5ff:fe1b:2d9e/64\n\n172.17.0.1/16\nfe80::ec78:50ff:fecc:50a4/64'
};
- it('add succeeds', async function () {
- const { id, accessToken } = await tokens.add(TOKEN_0);
- TOKEN_0.id = id;
- TOKEN_0.accessToken = accessToken;
- });
-
it('add fails with bad name', async function () {
const badToken = Object.assign({}, TOKEN_0);
badToken.name = new Array(100).fill('x').join('');
@@ -63,6 +58,19 @@ describe('Tokens', function () {
expect(error.reason).to.be(BoxError.BAD_FIELD);
});
+ it('add fails for bad allowed ips', async function () {
+ const badToken = Object.assign({}, TOKEN_0);
+ badToken.allowedIpRanges = '1.2.3./4';
+ const [error] = await safe(tokens.add(badToken));
+ expect(error.reason).to.be(BoxError.BAD_FIELD);
+ });
+
+ it('add succeeds', async function () {
+ const { id, accessToken } = await tokens.add(TOKEN_0);
+ TOKEN_0.id = id;
+ TOKEN_0.accessToken = accessToken;
+ });
+
it('get succeeds', async function () {
const result = await tokens.get(TOKEN_0.id);
expect(result).to.be.eql(TOKEN_0);
@@ -114,14 +122,16 @@ describe('Tokens', function () {
clientId: 'clientid-1',
expires: Number.MAX_SAFE_INTEGER,
lastUsedTime: null,
- scope: { '*': 'rw' }
+ scope: { '*': 'rw' },
+ allowedIpRanges: '#this is our server\n3.4.5.6'
};
const token2 = {
name: 'token2',
identifier: '2',
clientId: 'clientid-2',
expires: Date.now(),
- lastUsedTime: null
+ lastUsedTime: null,
+ allowedIpRanges: '#this'
};
let result = await tokens.add(token1);
@@ -148,14 +158,16 @@ describe('Tokens', function () {
clientId: tokens.ID_WEBADMIN,
expires: Number.MAX_SAFE_INTEGER,
lastUsedTime: null,
- scope: { '*': 'rw' }
+ scope: { '*': 'rw' },
+ allowedIpRanges: '#this is our server\n3.4.5.6'
};
const token2 = {
name: 'token2',
identifier: 'user1',
clientId: tokens.ID_SDK,
expires: Date.now(),
- lastUsedTime: null
+ lastUsedTime: null,
+ allowedIpRanges: '#this'
};
await tokens.add(token1);
diff --git a/src/test/user-directory-test.js b/src/test/user-directory-test.js
index 72b88aa6e..679ca9732 100644
--- a/src/test/user-directory-test.js
+++ b/src/test/user-directory-test.js
@@ -24,7 +24,7 @@ describe('User Directory', function () {
});
it('can set default profile config', async function () {
- await tokens.add({ name: 'token1', identifier: admin.id, clientId: tokens.ID_WEBADMIN, expires: Number.MAX_SAFE_INTEGER, lastUsedTime: null });
+ await tokens.add({ name: 'token1', identifier: admin.id, clientId: tokens.ID_WEBADMIN, expires: Number.MAX_SAFE_INTEGER, lastUsedTime: null, allowedIpRanges: '' });
let result = await tokens.listByUserId(admin.id);
expect(result.length).to.be(1); // just confirm the token was really added!
diff --git a/src/tokens.js b/src/tokens.js
index 15b4ec0bb..fc7f30b33 100644
--- a/src/tokens.js
+++ b/src/tokens.js
@@ -17,6 +17,8 @@ exports = module.exports = {
hasScope,
+ isIpAllowedSync,
+
// token client ids. we categorize them so we can have different restrictions based on the client
ID_WEBADMIN: 'cid-webadmin', // dashboard
ID_DEVELOPMENT: 'cid-development', // dashboard development
@@ -26,15 +28,18 @@ exports = module.exports = {
SCOPES: ['*']//, 'apps', 'domains'],
};
-const TOKENS_FIELDS = [ 'id', 'accessToken', 'identifier', 'clientId', 'scopeJson', 'expires', 'name', 'lastUsedTime' ].join(',');
+const TOKENS_FIELDS = [ 'id', 'accessToken', 'identifier', 'clientId', 'scopeJson', 'expires', 'name', 'lastUsedTime', 'allowedIpRanges' ].join(',');
const assert = require('assert'),
BoxError = require('./boxerror.js'),
database = require('./database.js'),
hat = require('./hat.js'),
+ ipaddr = require('ipaddr.js'),
safe = require('safetydance'),
uuid = require('uuid');
+const gParsedRangesCache = new Map(); // indexed by token.id
+
function postProcess(result) {
assert.strictEqual(typeof result, 'object');
@@ -88,10 +93,30 @@ function hasScope(token, method, path) {
}
}
+function parseIpRanges(ipRanges) {
+ assert.strictEqual(typeof ipRanges, 'string');
+
+ if (ipRanges.length === 0) return ['0.0.0.0/0', '::/0'];
+
+ const result = [];
+ for (const line of ipRanges.split('\n')) {
+ if (!line || line.startsWith('#')) continue;
+ // each line can have comma separated list. this complexity is because we changed the UI to take a line input instead of textarea
+ for (const entry of line.split(',')) {
+ const rangeOrIP = entry.trim();
+ // this checks for IPv4 and IPv6
+ if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `${rangeOrIP} is not a valid IP or range`);
+ result.push(rangeOrIP);
+ }
+ }
+
+ return result;
+}
+
async function add(token) {
assert.strictEqual(typeof token, 'object');
- const { clientId, identifier, expires } = token;
+ const { clientId, identifier, expires, allowedIpRanges } = token;
const name = token.name || '';
const scope = token.scope || { '*': 'rw' };
@@ -101,12 +126,14 @@ async function add(token) {
error = validateScope(scope);
if (error) throw error;
+ parseIpRanges(allowedIpRanges); // validate
+
const id = 'tid-' + uuid.v4();
const accessToken = token.accessToken || hat(8 * 32);
- await database.query('INSERT INTO tokens (id, accessToken, identifier, clientId, expires, scopeJson, name) VALUES (?, ?, ?, ?, ?, ?, ?)', [ id, accessToken, identifier, clientId, expires, JSON.stringify(scope), name ]);
+ await database.query('INSERT INTO tokens (id, accessToken, identifier, clientId, expires, scopeJson, name, allowedIpRanges) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [ id, accessToken, identifier, clientId, expires, JSON.stringify(scope), name, allowedIpRanges ]);
- return { id, accessToken, scope, clientId, identifier, expires, name };
+ return { id, accessToken, scope, clientId, identifier, expires, name, allowedIpRanges };
}
async function get(id) {
@@ -137,7 +164,7 @@ async function listByUserId(userId) {
async function getByAccessToken(accessToken) {
assert.strictEqual(typeof accessToken, 'string');
- const result = await database.query('SELECT ' + TOKENS_FIELDS + ' FROM tokens WHERE accessToken = ? AND expires > ?', [ accessToken, Date.now() ]);
+ const result = await database.query(`SELECT ${TOKENS_FIELDS} FROM tokens WHERE accessToken = ? AND expires > ?`, [ accessToken, Date.now() ]);
if (result.length === 0) return null;
return postProcess(result[0]);
}
@@ -174,6 +201,30 @@ async function update(id, values) {
}
args.push(id);
- const result = await database.query('UPDATE tokens SET ' + fields.join(', ') + ' WHERE id = ?', args);
+ const result = await database.query(`UPDATE tokens SET ${fields.join(', ')} WHERE id = ?`, args);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Token not found');
}
+
+function isIpAllowedSync(token, ip) {
+ assert.strictEqual(typeof token, 'object');
+ assert.strictEqual(typeof ip, 'string');
+
+ const parsedIp = ipaddr.process(ip);
+
+ let allowedIpRanges = gParsedRangesCache.get(token.id); // returns undefined if not found
+ if (!allowedIpRanges) {
+ allowedIpRanges = parseIpRanges(token.allowedIpRanges || '');
+ gParsedRangesCache.set(token.id, allowedIpRanges);
+ }
+
+ for (const ipOrRange of allowedIpRanges) {
+ if (!ipOrRange.includes('/')) {
+ if (ip === ipOrRange) return true;
+ } else {
+ const parsedRange = ipaddr.parseCIDR(ipOrRange); // returns [addr, range]
+ if (parsedRange[0].kind() === parsedIp.kind() && parsedIp.match(parsedRange)) return true;
+ }
+ }
+
+ return false;
+}
diff --git a/src/users.js b/src/users.js
index 212baa053..d338d3818 100644
--- a/src/users.js
+++ b/src/users.js
@@ -877,7 +877,7 @@ async function setupAccount(user, data, auditSource) {
await setPassword(user, data.password, auditSource);
- const token = { clientId: tokens.ID_WEBADMIN, identifier: user.id, expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS };
+ const token = { clientId: tokens.ID_WEBADMIN, identifier: user.id, expires: Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS, allowedIpRanges: '' };
const result = await tokens.add(token);
return result.accessToken;
}