[object Object]

← 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

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 →