← back to Rentv
security(pr-crm): tenant-scope setPassword — fix cross-tenant account takeover (TK-10290)
a18264229416a730b5f735afb8ea8c5bd30bae17 · 2026-08-10 07:47:17 -0700 · Steve Abrams
pr_users is RLS-exempt (login is pre-tenant), so POST /api/pr/users/:id/password
could reset ANY tenant's user password from a tenant admin (cross-tenant takeover).
Fix: setPassword now takes the caller's tenant_id and scopes the UPDATE (WHERE id AND
tenant_id), returning 404 on a cross-tenant target; the tenant-less CLI/worker path is
unchanged. Route passes req.prAuth.tenant.id. Reproduced + fix pre-validated on the DB;
new test/pr/setpassword-tenant-scope.test.js locks all three cases. Suite 95->99 green.
Steve-approved via pending-approval/rentv-pr-crm-cross-tenant-password-reset-TK-10290.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M src/pr/index.jsM src/pr/services/auth.jsA test/pr/setpassword-tenant-scope.test.js
Diff
commit a18264229416a730b5f735afb8ea8c5bd30bae17
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 10 07:47:17 2026 -0700
security(pr-crm): tenant-scope setPassword — fix cross-tenant account takeover (TK-10290)
pr_users is RLS-exempt (login is pre-tenant), so POST /api/pr/users/:id/password
could reset ANY tenant's user password from a tenant admin (cross-tenant takeover).
Fix: setPassword now takes the caller's tenant_id and scopes the UPDATE (WHERE id AND
tenant_id), returning 404 on a cross-tenant target; the tenant-less CLI/worker path is
unchanged. Route passes req.prAuth.tenant.id. Reproduced + fix pre-validated on the DB;
new test/pr/setpassword-tenant-scope.test.js locks all three cases. Suite 95->99 green.
Steve-approved via pending-approval/rentv-pr-crm-cross-tenant-password-reset-TK-10290.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
src/pr/index.js | 2 +-
src/pr/services/auth.js | 10 ++++--
test/pr/setpassword-tenant-scope.test.js | 56 ++++++++++++++++++++++++++++++++
3 files changed, 65 insertions(+), 3 deletions(-)
diff --git a/src/pr/index.js b/src/pr/index.js
index ccf53938..f7a0a2b9 100644
--- a/src/pr/index.js
+++ b/src/pr/index.js
@@ -141,7 +141,7 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
res.json(await auth.createUser({ tenant_id: req.prAuth.tenant.id, email: b.email, name: b.name, role: b.role, password: b.password }, req.prAuth.user.email));
}));
app.post('/api/pr/users/:id/password', adminOnly, requireCap('users'), h(async (req, res) => {
- await auth.setPassword(Number(req.params.id), (req.body || {}).password, req.prAuth.user.email);
+ await auth.setPassword(Number(req.params.id), (req.body || {}).password, req.prAuth.user.email, req.prAuth.tenant.id);
res.json({ ok: true });
}));
diff --git a/src/pr/services/auth.js b/src/pr/services/auth.js
index c8bd564c..4114b9ea 100644
--- a/src/pr/services/auth.js
+++ b/src/pr/services/auth.js
@@ -56,9 +56,15 @@ async function createUser({ tenant_id = 1, email, name, role = 'user', password
return row;
}
-async function setPassword(userId, password, actor) {
+async function setPassword(userId, password, actor, tenant_id) {
const password_hash = await hashPassword(password);
- await db.query('UPDATE pr_users SET password_hash=$2 WHERE id=$1', [userId, password_hash]);
+ // pr_users is RLS-EXEMPT (login is pre-tenant), so this write MUST scope tenant manually or a
+ // tenant admin could reset another tenant's user's password (cross-tenant takeover, TK-10290).
+ // tenant_id is optional so CLI/worker callers (no tenant context) keep full cross-tenant access.
+ const r = await db.query(
+ 'UPDATE pr_users SET password_hash=$2 WHERE id=$1' + (tenant_id != null ? ' AND tenant_id=$3' : ''),
+ tenant_id != null ? [userId, password_hash, tenant_id] : [userId, password_hash]);
+ if (tenant_id != null && r.rowCount === 0) throw new Error('user not found'); // → 404 via h(), no cross-tenant leak
await audit.log({ actor: actor || 'system', action: 'user.set_password', entity_type: 'user', entity_id: userId });
}
diff --git a/test/pr/setpassword-tenant-scope.test.js b/test/pr/setpassword-tenant-scope.test.js
new file mode 100644
index 00000000..ee1e7f0d
--- /dev/null
+++ b/test/pr/setpassword-tenant-scope.test.js
@@ -0,0 +1,56 @@
+'use strict';
+// Regression test for TK-10290: auth.setPassword must NOT cross tenants.
+// pr_users is RLS-exempt (login is pre-tenant), so setPassword scopes tenant manually via a
+// WHERE tenant_id clause. This test proves a tenant-A admin cannot reset a tenant-B user's
+// password (cross-tenant account takeover), while same-tenant resets + the no-tenant CLI path
+// still work. Runs against a THROWAWAY DB (rentv_pr_test); never touches rentv_pr.
+process.env.PR_DB_NAME = 'rentv_pr_test';
+delete process.env.PR_DATABASE_URL;
+
+const test = require('node:test');
+const assert = require('node:assert');
+const { execSync } = require('child_process');
+
+// Fresh throwaway DB (same terminate-then-recreate guard as integration.test.js — TK-10366 flake fix).
+execSync(
+ `psql -d postgres -tAc "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='rentv_pr_test' AND pid <> pg_backend_pid()" 2>/dev/null; `
+ + 'dropdb --if-exists rentv_pr_test 2>/dev/null; createdb rentv_pr_test',
+ { shell: '/bin/bash' },
+);
+
+const db = require('../../src/pr/db');
+const auth = require('../../src/pr/services/auth');
+
+test('setPassword is tenant-scoped (no cross-tenant password reset)', async (t) => {
+ await db.runMigrations({});
+ // Two tenants, one user each. Tenant 1 usually preexists from migrations; upsert both to be safe.
+ await db.query(`INSERT INTO pr_tenants (id, slug, name, status) VALUES (1,'t1','Tenant One','active')
+ ON CONFLICT (id) DO UPDATE SET status='active'`);
+ await db.query(`INSERT INTO pr_tenants (id, slug, name, status) VALUES (2,'t2','Tenant Two','active')
+ ON CONFLICT (id) DO UPDATE SET status='active'`);
+ const u1 = await auth.createUser({ tenant_id: 1, email: 'admin@t1.test', role: 'admin', password: 'orig-pw-1' }, 'test');
+ const u2 = await auth.createUser({ tenant_id: 2, email: 'admin@t2.test', role: 'admin', password: 'orig-pw-2' }, 'test');
+
+ await t.test('cross-tenant reset is REJECTED (tenant-1 caller targets tenant-2 user)', async () => {
+ await assert.rejects(
+ () => auth.setPassword(u2.id, 'pwned', 'admin@t1.test', /* caller tenant */ 1),
+ /user not found/i,
+ 'a tenant-1 admin must not be able to reset a tenant-2 user password',
+ );
+ // and tenant-2 user's password is unchanged → still logs in with its original password
+ const login = await auth.login({ email: 'admin@t2.test', password: 'orig-pw-2' });
+ assert.equal(login.ok, true, 'tenant-2 user password must be untouched by the blocked cross-tenant reset');
+ });
+
+ await t.test('same-tenant reset SUCCEEDS (tenant-1 caller targets its own user)', async () => {
+ await auth.setPassword(u1.id, 'new-pw-1', 'admin@t1.test', 1);
+ const login = await auth.login({ email: 'admin@t1.test', password: 'new-pw-1' });
+ assert.equal(login.ok, true, 'same-tenant password reset must work');
+ });
+
+ await t.test('no-tenant (CLI/worker) path still works — backward compatible', async () => {
+ await auth.setPassword(u2.id, 'cli-set-pw', 'system'); // no tenant_id arg
+ const login = await auth.login({ email: 'admin@t2.test', password: 'cli-set-pw' });
+ assert.equal(login.ok, true, 'the tenant-less CLI/worker path must retain full access');
+ });
+});
← 868b7939 auto-data-snapshot: 2026-08-10T07:21:36 (7 data files) — dat
·
back to Rentv
·
auto-data-snapshot: 2026-08-10T07:52:37 (7 data files) — dat 87c2b9fa →