fix remote HMAC: identify tenant by domain, not shared nodeServer

new.gholaktala.ir is the API host of many versions. Prefer subdomain of the product domain and refuse ambiguous nodeServer ties. 403 names the matched tenant.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 21:53:11 +03:30
co-authored by Cursor
parent 502b0c8b24
commit 3f0dfe448c
3 changed files with 87 additions and 10 deletions
+51
View File
@@ -0,0 +1,51 @@
import { findTenantForHost, scoreTenantHost } from './tenant-host.util';
const gholak = {
domain: 'gholaktala.ir',
domains: [],
appUrl: 'https://gholaktala.ir',
adminUrl: 'https://admin.gholaktala.ir',
nodeServer: 'https://new.gholaktala.ir/server',
};
const montzar = {
domain: 'k.montzar.ir',
domains: ['montzar.ir'],
appUrl: 'https://k.montzar.ir',
adminUrl: 'https://k.montzar.ir',
nodeServer: 'https://new.gholaktala.ir/server',
};
const otherOnSameBox = {
domain: 'examplegold.ir',
domains: [],
appUrl: 'https://examplegold.ir',
adminUrl: null,
nodeServer: 'https://new.gholaktala.ir/server',
};
describe('findTenantForHost', () => {
const tenants = [montzar, otherOnSameBox, gholak];
it('maps new.gholaktala.ir to gholaktala, not the first shared nodeServer tenant', () => {
expect(findTenantForHost(tenants, 'new.gholaktala.ir')?.domain).toBe(
'gholaktala.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', () => {
expect(findTenantForHost(tenants, 'k.montzar.ir')?.domain).toBe(
'k.montzar.ir',
);
});
it('does not pick a random tenant when only nodeServer matches several rows', () => {
const sharedOnly = [
{ ...montzar, domain: 'a.ir', domains: [] },
{ ...gholak, domain: 'b.ir', domains: [] },
];
expect(findTenantForHost(sharedOnly, 'new.gholaktala.ir')).toBeUndefined();
});
});
+25 -9
View File
@@ -54,10 +54,18 @@ function hostsEqual(left: string, right: string): boolean {
return left.replace(/^www\./, '') === right.replace(/^www\./, '');
}
/** new.gholaktala.ir belongs to tenant domain gholaktala.ir — not via shared nodeServer. */
function isSubdomainOf(host: string, parent: string): boolean {
const child = normalizeTenantHost(host);
const root = normalizeTenantHost(parent).replace(/^www\./, '');
if (!child || !root || !root.includes('.')) return false;
return child === root || child.endsWith('.' + root);
}
/**
* Same identity as GET /get_user?domain= — primary domain, then aliases,
* then app/admin/node URLs so HMAC and get_user cannot disagree.
* Origin پنل ادمین (دامنه یا آی‌پی LAN داخل adminUrl) هم همین‌جا مچ می‌شود.
* then app/admin URLs. nodeServer is last and only if unique (many versions
* share one API host like new.gholaktala.ir).
*/
export function scoreTenantHost(
tenant: TenantHostFields,
@@ -75,6 +83,10 @@ export function scoreTenantHost(
for (const value of readHostList(tenant.appUrl)) {
if (eq(value)) return 80;
}
if (tenant.domain && isSubdomainOf(host, tenant.domain)) return 75;
for (const alias of readHostList(tenant.domains)) {
if (isSubdomainOf(host, alias)) return 72;
}
for (const value of readHostList(tenant.adminUrl)) {
const normalized = normalizeTenantHost(value);
if (looksLikeHostname(normalized) && hostsEqual(normalized, host)) {
@@ -91,11 +103,15 @@ export function findTenantForHost<T extends TenantHostFields>(
tenants: T[],
host: string,
): T | undefined {
let best: { row: T; score: number } | undefined;
for (const row of tenants) {
const score = scoreTenantHost(row, host);
if (score <= 0) continue;
if (!best || score > best.score) best = { row, score };
}
return best?.row;
const scored = tenants
.map((row) => ({ row, score: scoreTenantHost(row, host) }))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score);
if (!scored.length) return undefined;
const best = scored[0].score;
const tied = scored.filter((item) => item.score === best);
// nodeServer مشترک یا دو نسخه با امتیاز مساوی — حدس نزن
if (tied.length !== 1) return undefined;
return tied[0].row;
}
+11 -1
View File
@@ -810,7 +810,17 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy {
}
if (!tenantId) throw new ForbiddenException('نسخه نامعتبر است');
if (!(await this.isStoreEnabled(tenantId))) {
throw new ForbiddenException('فروشگاه افزونه برای این نسخه خاموش است');
const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId },
select: { nameFa: true, domain: true },
});
const who = tenant
? `${tenant.nameFa} (${tenant.domain})`
: tenantId;
this.logger.warn(`plugin store off for ${who}`);
throw new ForbiddenException(
`فروشگاه افزونه برای این نسخه خاموش است: ${who}`,
);
}
}