fix remote HMAC: match tenant domain only, not shared adminUrl

k.montzar.ir on Gholaktala adminUrl was selecting tenant app.gholaktala.ir. HMAC now uses primary domain (and its subdomain), not admin/app/node URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 22:07:40 +03:30
co-authored by Cursor
parent 3f0dfe448c
commit 82e74f86f0
3 changed files with 42 additions and 16 deletions
+5 -1
View File
@@ -160,7 +160,11 @@ export class RemoteSmsAuthGuard implements CanActivate {
}, },
}); });
const hit = findTenantForHost(tenants as TenantHostRow[], normalizedHost); const hit = findTenantForHost(
tenants as TenantHostRow[],
normalizedHost,
'hmac',
);
if (!hit) return null; if (!hit) return null;
return { return {
+24 -10
View File
@@ -1,10 +1,10 @@
import { findTenantForHost, scoreTenantHost } from './tenant-host.util'; import { findTenantForHost, scoreTenantHost } from './tenant-host.util';
const gholak = { const gholak = {
domain: 'gholaktala.ir', domain: 'app.gholaktala.ir',
domains: [], domains: [],
appUrl: 'https://gholaktala.ir', appUrl: 'https://app.gholaktala.ir',
adminUrl: 'https://admin.gholaktala.ir', adminUrl: 'https://k.montzar.ir/admin',
nodeServer: 'https://new.gholaktala.ir/server', nodeServer: 'https://new.gholaktala.ir/server',
}; };
@@ -25,22 +25,36 @@ const otherOnSameBox = {
}; };
describe('findTenantForHost', () => { describe('findTenantForHost', () => {
const tenants = [montzar, otherOnSameBox, gholak]; const tenants = [gholak, otherOnSameBox, montzar];
it('maps new.gholaktala.ir to gholaktala, not the first shared nodeServer tenant', () => { it('hmac does not treat shared adminUrl as gholaktala', () => {
expect(findTenantForHost(tenants, 'new.gholaktala.ir')?.domain).toBe( expect(scoreTenantHost(gholak, 'k.montzar.ir', 'hmac')).toBe(0);
'gholaktala.ir', expect(findTenantForHost(tenants, 'k.montzar.ir', 'hmac')?.domain).toBe(
'k.montzar.ir',
); );
expect(scoreTenantHost(gholak, 'new.gholaktala.ir')).toBe(75);
expect(scoreTenantHost(montzar, 'new.gholaktala.ir')).toBe(10);
}); });
it('maps k.montzar.ir to montzar by primary domain', () => { it('hmac without montzar tenant does not fall back to gholak adminUrl', () => {
expect(
findTenantForHost([gholak, otherOnSameBox], 'k.montzar.ir', 'hmac'),
).toBeUndefined();
});
it('full get_user still maps k.montzar.ir to montzar by primary domain', () => {
expect(findTenantForHost(tenants, 'k.montzar.ir')?.domain).toBe( expect(findTenantForHost(tenants, 'k.montzar.ir')?.domain).toBe(
'k.montzar.ir', 'k.montzar.ir',
); );
}); });
it('maps new.gholaktala.ir to gholaktala via subdomain, not nodeServer', () => {
const gholakBare = { ...gholak, domain: 'gholaktala.ir' };
expect(
findTenantForHost([montzar, otherOnSameBox, gholakBare], 'new.gholaktala.ir')
?.domain,
).toBe('gholaktala.ir');
expect(scoreTenantHost(gholakBare, 'new.gholaktala.ir')).toBe(75);
});
it('does not pick a random tenant when only nodeServer matches several rows', () => { it('does not pick a random tenant when only nodeServer matches several rows', () => {
const sharedOnly = [ const sharedOnly = [
{ ...montzar, domain: 'a.ir', domains: [] }, { ...montzar, domain: 'a.ir', domains: [] },
+13 -5
View File
@@ -62,14 +62,17 @@ function isSubdomainOf(host: string, parent: string): boolean {
return child === root || child.endsWith('.' + root); return child === root || child.endsWith('.' + root);
} }
export type TenantHostMatchMode = 'full' | 'hmac';
/** /**
* Same identity as GET /get_user?domain= — primary domain, then aliases, * full: get_user — دامنه، alias، app/admin/node.
* then app/admin URLs. nodeServer is last and only if unique (many versions * hmac: فقط دامنهٔ اصلی نسخه (و ساب‌دامنهٔ همان). adminUrl مشترک
* share one API host like new.gholaktala.ir). * (k.montzar.ir روی نسخهٔ app.gholaktala.ir) هویت نیست.
*/ */
export function scoreTenantHost( export function scoreTenantHost(
tenant: TenantHostFields, tenant: TenantHostFields,
hostRaw: string, hostRaw: string,
mode: TenantHostMatchMode = 'full',
): number { ): number {
const host = normalizeTenantHost(hostRaw); const host = normalizeTenantHost(hostRaw);
if (!host) return 0; if (!host) return 0;
@@ -77,6 +80,11 @@ export function scoreTenantHost(
hostsEqual(normalizeTenantHost(raw), host); hostsEqual(normalizeTenantHost(raw), host);
if (eq(tenant.domain)) return 100; if (eq(tenant.domain)) return 100;
if (mode === 'hmac') {
if (tenant.domain && isSubdomainOf(host, tenant.domain)) return 75;
return 0;
}
for (const alias of readHostList(tenant.domains)) { for (const alias of readHostList(tenant.domains)) {
if (eq(alias)) return 90; if (eq(alias)) return 90;
} }
@@ -102,16 +110,16 @@ export function scoreTenantHost(
export function findTenantForHost<T extends TenantHostFields>( export function findTenantForHost<T extends TenantHostFields>(
tenants: T[], tenants: T[],
host: string, host: string,
mode: TenantHostMatchMode = 'full',
): T | undefined { ): T | undefined {
const scored = tenants const scored = tenants
.map((row) => ({ row, score: scoreTenantHost(row, host) })) .map((row) => ({ row, score: scoreTenantHost(row, host, mode) }))
.filter((item) => item.score > 0) .filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score); .sort((a, b) => b.score - a.score);
if (!scored.length) return undefined; if (!scored.length) return undefined;
const best = scored[0].score; const best = scored[0].score;
const tied = scored.filter((item) => item.score === best); const tied = scored.filter((item) => item.score === best);
// nodeServer مشترک یا دو نسخه با امتیاز مساوی — حدس نزن
if (tied.length !== 1) return undefined; if (tied.length !== 1) return undefined;
return tied[0].row; return tied[0].row;
} }