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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user