fix(settings-proxy): push kill-switch with SMS shared HMAC

When remoteApiToken is empty, sign SuperAdmin→product /super/settings
with the same SMS_REMOTE_SHARED_SECRET + domain headers used by SMS.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 19:33:13 +03:30
co-authored by Cursor
parent 5a268faa94
commit 8fe7d59c1b
+99 -6
View File
@@ -3,26 +3,32 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { createHmac } from 'crypto';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
const SUPER_SETTINGS_CANONICAL_PATH = '/api/super/settings';
@Injectable()
export class SettingsProxyService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly config: ConfigService,
) {}
async getSettings(tenantId: string) {
const tenant = await this.requireTenant(tenantId);
const auth = this.buildAuthHeaders(tenant, 'GET');
if (!tenant.remoteApiToken) {
if (!auth) {
return {
stub: true,
message:
'remoteApiToken تنظیم نشده. فعلاً stub است تا endpoint سوپر روی Laravel اضافه شود.',
'نه remoteApiToken و نه SMS_REMOTE_SHARED_SECRET تنظیم شده؛ push ممکن نیست.',
tenant: {
id: tenant.id,
slug: tenant.slug,
@@ -35,7 +41,7 @@ export class SettingsProxyService {
try {
const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), {
headers: {
Authorization: `Bearer ${tenant.remoteApiToken}`,
...auth,
Accept: 'application/json',
},
timeout: 15000,
@@ -54,8 +60,9 @@ export class SettingsProxyService {
userId?: string,
) {
const tenant = await this.requireTenant(tenantId);
const auth = this.buildAuthHeaders(tenant, 'POST');
if (!tenant.remoteApiToken) {
if (!auth) {
await this.audit.log({
action: 'settings.proxy.stub_update',
entityType: 'tenant_settings',
@@ -69,7 +76,7 @@ export class SettingsProxyService {
return {
stub: true,
message:
'remoteApiToken تنظیم نشده؛ تغییر فقط در audit ثبت شد (stub).',
'نه remoteApiToken و نه SMS_REMOTE_SHARED_SECRET تنظیم شده؛ تغییر فقط در audit ثبت شد (stub).',
accepted: payload,
};
}
@@ -80,7 +87,7 @@ export class SettingsProxyService {
payload,
{
headers: {
Authorization: `Bearer ${tenant.remoteApiToken}`,
...auth,
Accept: 'application/json',
'Content-Type': 'application/json',
},
@@ -95,6 +102,9 @@ export class SettingsProxyService {
tenantId,
userId,
after: payload as Prisma.InputJsonValue,
meta: {
authMode: auth.Authorization ? 'bearer' : 'hmac',
} as Prisma.InputJsonValue,
});
return { stub: false, result: data };
@@ -105,6 +115,89 @@ export class SettingsProxyService {
}
}
/**
* Prefer per-tenant Bearer; else same shared HMAC used by SMS remote.
* Canonical path is always /api/super/settings so product middleware can verify
* regardless of /server prefix on nodeServer.
*/
private buildAuthHeaders(
tenant: {
remoteApiToken: string | null;
domain: string | null;
domains: unknown;
nodeServer: string | null;
},
method: 'GET' | 'POST',
): Record<string, string> | null {
const token = String(tenant.remoteApiToken || '').trim();
if (token) {
return { Authorization: `Bearer ${token}` };
}
const shared = String(
this.config.get<string>('SMS_REMOTE_SHARED_SECRET') || '',
).trim();
const domain = this.resolveTenantDomain(tenant);
if (!shared || !domain) {
return null;
}
const timestamp = String(Math.floor(Date.now() / 1000));
const payload = `${timestamp}.${method}.${SUPER_SETTINGS_CANONICAL_PATH}.${domain}`;
const signature = createHmac('sha256', shared)
.update(payload, 'utf8')
.digest('hex');
return {
'X-Abshode-Domain': domain,
'X-Abshode-Timestamp': timestamp,
'X-Abshode-Signature': signature,
};
}
private resolveTenantDomain(tenant: {
domain: string | null;
domains: unknown;
nodeServer: string | null;
}): string {
// Prefer host of the URL we call — must match product SUPERADMIN_SMS_DOMAIN
const fromNode = this.normalizeHost(tenant.nodeServer);
if (fromNode) return fromNode;
const primary = this.normalizeHost(tenant.domain);
if (primary) return primary;
for (const value of this.readDomains(tenant.domains)) {
const host = this.normalizeHost(value);
if (host) return host;
}
return '';
}
private readDomains(raw: unknown): string[] {
if (!raw) return [];
if (Array.isArray(raw)) return raw.map(String);
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
return [];
}
private normalizeHost(value?: string | null) {
if (!value) return '';
return String(value)
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.split('/')[0]
.split(':')[0];
}
private async requireTenant(tenantId: string) {
const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId },