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, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios'; import axios from 'axios';
import { createHmac } from 'crypto';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
const SUPER_SETTINGS_CANONICAL_PATH = '/api/super/settings';
@Injectable() @Injectable()
export class SettingsProxyService { export class SettingsProxyService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly config: ConfigService,
) {} ) {}
async getSettings(tenantId: string) { async getSettings(tenantId: string) {
const tenant = await this.requireTenant(tenantId); const tenant = await this.requireTenant(tenantId);
const auth = this.buildAuthHeaders(tenant, 'GET');
if (!tenant.remoteApiToken) { if (!auth) {
return { return {
stub: true, stub: true,
message: message:
'remoteApiToken تنظیم نشده. فعلاً stub است تا endpoint سوپر روی Laravel اضافه شود.', 'نه remoteApiToken و نه SMS_REMOTE_SHARED_SECRET تنظیم شده؛ push ممکن نیست.',
tenant: { tenant: {
id: tenant.id, id: tenant.id,
slug: tenant.slug, slug: tenant.slug,
@@ -35,7 +41,7 @@ export class SettingsProxyService {
try { try {
const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), { const { data } = await axios.get(this.resolveSettingsUrl(tenant.nodeServer), {
headers: { headers: {
Authorization: `Bearer ${tenant.remoteApiToken}`, ...auth,
Accept: 'application/json', Accept: 'application/json',
}, },
timeout: 15000, timeout: 15000,
@@ -54,8 +60,9 @@ export class SettingsProxyService {
userId?: string, userId?: string,
) { ) {
const tenant = await this.requireTenant(tenantId); const tenant = await this.requireTenant(tenantId);
const auth = this.buildAuthHeaders(tenant, 'POST');
if (!tenant.remoteApiToken) { if (!auth) {
await this.audit.log({ await this.audit.log({
action: 'settings.proxy.stub_update', action: 'settings.proxy.stub_update',
entityType: 'tenant_settings', entityType: 'tenant_settings',
@@ -69,7 +76,7 @@ export class SettingsProxyService {
return { return {
stub: true, stub: true,
message: message:
'remoteApiToken تنظیم نشده؛ تغییر فقط در audit ثبت شد (stub).', 'نه remoteApiToken و نه SMS_REMOTE_SHARED_SECRET تنظیم شده؛ تغییر فقط در audit ثبت شد (stub).',
accepted: payload, accepted: payload,
}; };
} }
@@ -80,7 +87,7 @@ export class SettingsProxyService {
payload, payload,
{ {
headers: { headers: {
Authorization: `Bearer ${tenant.remoteApiToken}`, ...auth,
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -95,6 +102,9 @@ export class SettingsProxyService {
tenantId, tenantId,
userId, userId,
after: payload as Prisma.InputJsonValue, after: payload as Prisma.InputJsonValue,
meta: {
authMode: auth.Authorization ? 'bearer' : 'hmac',
} as Prisma.InputJsonValue,
}); });
return { stub: false, result: data }; 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) { private async requireTenant(tenantId: string) {
const tenant = await this.prisma.tenant.findUnique({ const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId }, where: { id: tenantId },