From 3f0dfe448c1bbab486d21859e65ff726ca052642 Mon Sep 17 00:00:00 2001 From: adonis Date: Wed, 9 Sep 2026 21:53:11 +0330 Subject: [PATCH] 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 --- src/auth/tenant-host.util.spec.ts | 51 +++++++++++++++++++++++++++++ src/auth/tenant-host.util.ts | 34 ++++++++++++++----- src/plugins/plugin-store.service.ts | 12 ++++++- 3 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 src/auth/tenant-host.util.spec.ts diff --git a/src/auth/tenant-host.util.spec.ts b/src/auth/tenant-host.util.spec.ts new file mode 100644 index 0000000..650a57b --- /dev/null +++ b/src/auth/tenant-host.util.spec.ts @@ -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(); + }); +}); diff --git a/src/auth/tenant-host.util.ts b/src/auth/tenant-host.util.ts index 13369bf..18195cc 100644 --- a/src/auth/tenant-host.util.ts +++ b/src/auth/tenant-host.util.ts @@ -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( 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; } diff --git a/src/plugins/plugin-store.service.ts b/src/plugins/plugin-store.service.ts index d10c193..22d5765 100644 --- a/src/plugins/plugin-store.service.ts +++ b/src/plugins/plugin-store.service.ts @@ -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}`, + ); } }