From 8a5532b0e469d6340d8fc264d378bc0e532bed68 Mon Sep 17 00:00:00 2001 From: steunix Date: Sat, 2 Aug 2025 22:37:23 +0200 Subject: [PATCH] Implement API key IP addresses whitelist Fixes #309 --- api/v1/controllers/apikeys.mjs | 18 +++++++- api/v1/controllers/login.mjs | 14 +++++- docs/apidoc/paths/apikeys.yaml | 4 +- docs/apidoc/requestbodies/apikeys.yaml | 14 ++++++ docs/apidoc/responsebodies/apikeys.yaml | 10 ++++- docs/index.md | 4 +- lib/const.mjs | 4 +- lib/schemas/apikey_create.json | 3 +- model/apikey.mjs | 33 ++++++++++++++ package-lock.json | 57 ++++++++++++++++++++++--- package.json | 4 +- prisma/schema.prisma | 1 + test/apikeys.cjs | 25 ++++++++++- test/login.spec.cjs | 26 +++++++++++ 14 files changed, 201 insertions(+), 16 deletions(-) diff --git a/api/v1/controllers/apikeys.mjs b/api/v1/controllers/apikeys.mjs index d6cbfa1..857fb9a 100644 --- a/api/v1/controllers/apikeys.mjs +++ b/api/v1/controllers/apikeys.mjs @@ -13,6 +13,7 @@ import * as JV from '../../../lib/jsonvalidator.mjs' import * as Crypt from '../../../lib/crypt.mjs' import * as KMS from '../../../lib/kms/kms.mjs' import * as User from '../../../model/user.mjs' +import * as APIKey from '../../../model/apikey.mjs' import { isAdmin, isReadOnly } from '../../../lib/auth.mjs' import DB from '../../../lib/db.mjs' @@ -113,6 +114,11 @@ export async function create (req, res, next) { return } + if (req.body.ipwhitelist && !APIKey.validateCIDRList(req.body.ipwhitelist)) { + res.status(R.BAD_REQUEST).send(R.badRequest('Invalid IP whitelist format')) + return + } + // Creates the API key const secret = Crypt.randomString(20) const encsecret = await KMS.encrypt(secret, 'aes-256-gcm') @@ -129,7 +135,8 @@ export async function create (req, res, next) { description: req.body.description, userid: req.body.userid, expiresat: req.body.expiresat + 'T00:00:00.000Z', - active: req.body.active + active: req.body.active, + ipwhitelist: req.body.ipwhitelist || null } }) @@ -188,6 +195,15 @@ export async function update (req, res, next) { if (Object.prototype.hasOwnProperty.call(req.body, 'active')) { updateStruct.active = req.body.active } + if (Object.prototype.hasOwnProperty.call(req.body, 'ipwhitelist')) { + // Validate IP whitelist + if (!APIKey.validateCIDRList(req.body.ipwhitelist)) { + res.status(R.BAD_REQUEST).send(R.badRequest('Invalid IP whitelist format')) + return + } + + updateStruct.ipwhitelist = req.body.ipwhitelist || null + } await DB.apikeys.update({ data: updateStruct, diff --git a/api/v1/controllers/login.mjs b/api/v1/controllers/login.mjs index f8c4073..a648ca0 100644 --- a/api/v1/controllers/login.mjs +++ b/api/v1/controllers/login.mjs @@ -56,10 +56,10 @@ export async function login (req, res, next) { return } - // Search the API key anc check if it is active + // Search the API key and check if it is active const apik = await DB.apikeys.findUnique({ where: { id: data.apikey }, - select: { userid: true, active: true } + select: { userid: true, active: true, ipwhitelist: true } }) if (!apik.active) { Events.add(data.username, Const.EV_ACTION_LOGIN_APIKEY_NOTVALID, Const.EV_ENTITY_APIKEY, data.apikey) @@ -67,6 +67,16 @@ export async function login (req, res, next) { return } + // Check if IP is whitelisted + if (apik.ipwhitelist) { + const clientIp = req.connection.remoteAddress + if (!ApiKey.checkIPWhitelist(apik.ipwhitelist, clientIp)) { + Events.add(data.username, Const.EV_ACTION_LOGIN_APIKEY_IPNOTWHITELISTED, Const.EV_ENTITY_APIKEY, data.apikey) + res.status(R.UNAUTHORIZED).send(R.ko('API key not valid')) + return + } + } + // Get corresponding user const user = await DB.users.findUnique({ where: { id: apik.userid }, diff --git a/docs/apidoc/paths/apikeys.yaml b/docs/apidoc/paths/apikeys.yaml index 455334b..f308939 100644 --- a/docs/apidoc/paths/apikeys.yaml +++ b/docs/apidoc/paths/apikeys.yaml @@ -24,8 +24,8 @@ post: tags: - API keys operationId: "createAPIKey" - summary: Create API key - description: Create API key + summary: Create an API key + description: Create an API key requestBody: content: application/json: diff --git a/docs/apidoc/requestbodies/apikeys.yaml b/docs/apidoc/requestbodies/apikeys.yaml index c6fa072..15b6920 100644 --- a/docs/apidoc/requestbodies/apikeys.yaml +++ b/docs/apidoc/requestbodies/apikeys.yaml @@ -6,15 +6,22 @@ apikeyCreateBody: description: type: string description: Description + example: 'API key for automated client' userid: type: string description: Connected user + example: 'user_123' expiresat: type: string description: Expiring date, YYYY-MM-DD + example: '2023-12-31' active: type: boolean description: Active status + ipwhitelist: + type: string + description: Comma-separated list of IPs or CIDR ranges + example: '192.168.1.1/32,192.168.2.0/24' required: - description - userid @@ -27,12 +34,19 @@ apikeyUpdateBody: description: type: string description: Description + example: 'Updated API key description' userid: type: string description: Connected user + example: 'user_123' expiresat: type: string description: Expiring date, YYYY-MM-DD + example: '2023-12-31' active: type: boolean description: Active status + ipwhitelist: + type: string + description: Comma-separated list of IPs or CIDR ranges + example: '192.168.1.1/32,192.168.2.0/24' \ No newline at end of file diff --git a/docs/apidoc/responsebodies/apikeys.yaml b/docs/apidoc/responsebodies/apikeys.yaml index 878aa94..ad32cd0 100644 --- a/docs/apidoc/responsebodies/apikeys.yaml +++ b/docs/apidoc/responsebodies/apikeys.yaml @@ -24,13 +24,17 @@ getsuccess: description: Description userid: type: string - description: Connected user + description: Connected user ID expiresat: type: string description: Expiring date active: type: boolean description: Active status + ipwhitelist: + type: string + description: Comma-separated list of IPs or CIDR ranges + example: '192.168.1.1/32,192.168.2.0/24' createdat: type: string description: Creation time @@ -70,6 +74,10 @@ listsuccess: active: type: boolean description: Active status + ipwhitelist: + type: string + description: Comma-separated list of IPs or CIDR ranges + example: '192.168.1.1/32,192.168.2.0/24' createdat: type: string description: Creation time diff --git a/docs/index.md b/docs/index.md index 89f1a18..42e8dd4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,7 +27,7 @@ This are the software you need to have in order to run PassWeaver API: These are the features this API support, in random order: - Cloud KMS integration (currently, only Google Cloud KMS) -- API keys for software consumers +- API keys, with IP whitelist - Personal folders for each user - Favorite items - Share one-time secrets with anyone, even if they have not an account @@ -122,6 +122,8 @@ You can share both random text, or an entire item. API keys can be created to easier credential handling in case of automated clients. An API key is bound to a user, whose authentication method must be 'apikey': this way you can easily manage permissions as you would do for a regular user (assigning it to a group), without the need of exposing users password or to disrupt functionalities in case the user changes it. +For each API key you can optionally define a whitelist of CIDR to restrict access. + You can create as many API keys you need for a given user and activate/disactivate them at any time. They also have an expiration date. ## Authentication diff --git a/lib/const.mjs b/lib/const.mjs index 2512598..9d5d2e5 100644 --- a/lib/const.mjs +++ b/lib/const.mjs @@ -40,7 +40,8 @@ export const EV_ACTION_LOGINFAILED = 63 export const EV_ACTION_LOGIN_APIKEY = 64 export const EV_ACTION_LOGIN_APIKEY_NOTFOUND = 65 export const EV_ACTION_LOGIN_APIKEY_NOTVALID = 66 -export const EV_ACTION_LOGIN_APIKEY_FAILED = 66 +export const EV_ACTION_LOGIN_APIKEY_FAILED = 67 +export const EV_ACTION_LOGIN_APIKEY_IPNOTWHITELISTED = 68 export const EV_ACTION_UNLOCKNF = 70 export const EV_ACTION_UNLOCKNV = 71 export const EV_ACTION_UNLOCK = 72 @@ -82,6 +83,7 @@ export const actionDescriptions = { 65: 'API key not found', 66: 'API key not valid', 67: 'API key bad secret', + 68: 'API key IP not whitelisted', 70: 'Personal folder password not set', 71: 'Personal folder password mismatch', 72: 'Personal folder unlocked', diff --git a/lib/schemas/apikey_create.json b/lib/schemas/apikey_create.json index 03edd15..041a7b9 100644 --- a/lib/schemas/apikey_create.json +++ b/lib/schemas/apikey_create.json @@ -5,7 +5,8 @@ "description" : { "type": "string", "maxLength": 100 }, "userid": { "type": "string", "maxLength": 50}, "expiresat" : { "type": "string" }, - "active": { "type": "boolean" } + "active": { "type": "boolean" }, + "ipwhitelist": { "type": "string", "maxLength": 500, "nullable": true } }, "required": ["description", "userid", "expiresat", "active"] } \ No newline at end of file diff --git a/model/apikey.mjs b/model/apikey.mjs index ac9794f..da8ece8 100644 --- a/model/apikey.mjs +++ b/model/apikey.mjs @@ -8,6 +8,8 @@ import DB from '../lib/db.mjs' import * as KMS from '../lib/kms/kms.mjs' +import isInSubnet from 'is-in-subnet' +import isCIDR from 'is-cidr' /** * Check if API key exists @@ -41,3 +43,34 @@ export async function checkSecret (apikey, secret) { const dec = await KMS.decrypt(apik.kmsid, apik.dek, apik.secret, apik.secretiv, apik.secretauthtag, apik.algorithm) return (dec === secret) } + +/** + * Validate string as a valid list of CIDR ranges + * @param {string} cidrlist Comma-separated list of IPs or CIDR ranges + * @returns {boolean} True if valid, false otherwise + */ +export function validateCIDRList (cidrlist) { + if (!cidrlist) return true // Empty list is valid + + const cidrs = cidrlist.split(',') + for (const cidr of cidrs) { + const tcdr = cidr.trim() + if (!isCIDR(tcdr)) { + return false + } + } + return true +} + +/** + * Check if IP is contained in the whitelist of CIDR ranges + * @param {string} cidrlist Comma-separated list of CIDR ranges + * @param {string} ip IP address to check + * @returns {boolean} True if IP is whitelisted, false otherwise + */ +export function checkIPWhitelist (cidrlist, ip) { + if (!cidrlist) return true + + const cidrs = cidrlist.split(',') + return isInSubnet.isInSubnet(ip, cidrs) +} diff --git a/package-lock.json b/package-lock.json index 9dd2647..6f4cc43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "express-rate-limit": "8.0.1", "generate-password": "1.7.1", "helmet": "8.1.0", + "is-cidr": "6.0.0", + "is-in-subnet": "4.0.1", "jsonwebtoken": "9.0.2", "ldapts": "8.0.9", "morgan": "1.10.1", @@ -1402,6 +1404,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cidr-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cidr-regex/-/cidr-regex-5.0.0.tgz", + "integrity": "sha512-9FT511D25oLAQYkfKLqWUMzoitgITToOqNThDAM8ujXaeXDulDPffJQflag918J8DN8mUPXRpS9J3U5GlIHGSQ==", + "license": "BSD-2-Clause", + "dependencies": { + "ip-regex": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -4014,13 +4028,16 @@ "node": ">= 12" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/ip-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz", + "integrity": "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==", "license": "MIT", "engines": { - "node": ">= 0.10" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-array-buffer": { @@ -4114,6 +4131,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-cidr": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/is-cidr/-/is-cidr-6.0.0.tgz", + "integrity": "sha512-LM62mX6QmYvLL7c0AZ2rnqGUAHcgkNwre56e8rrAdRLjUmwqrOrqGj6E/iVSrL7xxZfGQUR0gBVx9pW5CLIbig==", + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -4232,6 +4261,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-in-subnet": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-in-subnet/-/is-in-subnet-4.0.1.tgz", + "integrity": "sha512-D3mAuAo6vZ+/AxsLkEIZ3moTx7AIGQLLzLQslV6n0RRO/CzdUemXap+lj3OPAehKCbdkGPikxOVUYqRo0GGJAA==", + "license": "MIT", + "engines": { + "node": ">=10.23.0" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -5896,6 +5934,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index 8fcd42f..1c77027 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,9 @@ "prisma": "6.13.0", "redis": "5.6.1", "rotating-file-stream": "3.2.6", - "uuidv7": "1.0.2" + "uuidv7": "1.0.2", + "is-in-subnet": "4.0.1", + "is-cidr": "6.0.0" }, "prisma": { "seed": "node prisma/seed.cjs" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index db7142c..4fc475a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -272,6 +272,7 @@ model apikeys { userid String expiresat DateTime active Boolean + ipwhitelist String? createdat DateTime @default(now()) updatedat DateTime @updatedAt diff --git a/test/apikeys.cjs b/test/apikeys.cjs index e7fbbb0..e61e676 100644 --- a/test/apikeys.cjs +++ b/test/apikeys.cjs @@ -7,7 +7,7 @@ describe('API keys', () => { const res1 = await global.agent .post(`${global.host}/api/v1/apikeys`) .set('Authorization', `Bearer ${global.adminJWT}`) - .send({ description: 'test api key', userid: '0', expiresat: '2050-01-01', active: true }) + .send({ description: 'test api key', userid: '0', expiresat: '2050-01-01', active: true, ipwhitelist: '192.160.0.0/24,fd44:9ba1:1234:aa1b::/64' }) .catch(v => v) assert.strictEqual(res1.status, 201) @@ -38,6 +38,29 @@ describe('API keys', () => { assert.strictEqual(res1.status, 400) }) + it('Create API key bad IP whitelist', async () => { + const res1 = await agent + .post(`${global.host}/api/v1/apikeys`) + .set('Authorization', `Bearer ${global.adminJWT}`) + .send({ description: 'test api key', userid: '0', expiresat: '2050-01-01', active: true, ipwhitelist: 'abc' }) + .catch(v => v) + assert.strictEqual(res1.status, 400) + + const res2 = await agent + .post(`${global.host}/api/v1/apikeys`) + .set('Authorization', `Bearer ${global.adminJWT}`) + .send({ description: 'test api key', userid: '0', expiresat: '2050-01-01', active: true, ipwhitelist: '192.168.0.0/24,abc' }) + .catch(v => v) + assert.strictEqual(res2.status, 400) + + const res3 = await agent + .post(`${global.host}/api/v1/apikeys`) + .set('Authorization', `Bearer ${global.adminJWT}`) + .send({ description: 'test api key', userid: '0', expiresat: '2050-01-01', active: true, ipwhitelist: '192.168.0.0/24,a084:1e02:3852:e9f7:9a21' }) + .catch(v => v) + assert.strictEqual(res3.status, 400) + }) + it('Create API key bad user', async () => { const res1 = await agent .post(`${global.host}/api/v1/apikeys`) diff --git a/test/login.spec.cjs b/test/login.spec.cjs index effb911..e8bd002 100644 --- a/test/login.spec.cjs +++ b/test/login.spec.cjs @@ -94,6 +94,32 @@ describe('Login', function () { assert.strictEqual(res2.status, 401) }) + it('Login via API, IP not whitelisted', async function () { + const res1 = await global.agent + .post(`${global.host}/api/v1/apikeys`) + .set('Authorization', `Bearer ${global.adminJWT}`) + .send({ description: 'test api key', userid: '0', expiresat: '2050-01-01', active: true, ipwhitelist: '1.1.1.1/32' }) + .catch(v => v) + + assert.strictEqual(res1.status, 201) + const apikId = res1.body.data.id + const secret = res1.body.data.secret + + const res2 = await agent + .post(`${global.host}/api/v1/login`) + .send({ apikey: apikId, secret }) + .catch(v => v) + + assert.strictEqual(res2.status, 401) + + const res3 = await agent + .delete(`${global.host}/api/v1/apikeys/${apikId}`) + .set('Authorization', `Bearer ${global.adminJWT}`) + .catch(v => v) + + assert.strictEqual(res3.status, 200) + }) + it('Login via API, bad secret', async function () { const res1 = await global.agent .post(`${global.host}/api/v1/apikeys`)