feat: enforce signature-scoped drainage authorization before SMS submission

This commit is contained in:
hectorzhao
2026-09-10 13:29:04 +08:00
parent 5bcdbb2a03
commit 0c3f820cc9
35 changed files with 2769 additions and 791 deletions
@@ -0,0 +1,243 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { isIP } from 'node:net';
import { parse } from 'tldts';
import type { PrismaService } from '../prisma/prisma.service';
import { detectDrainageContent, normalizeContent, type DrainageDetectionMatch } from './drainage-content-detection';
export const DRAINAGE_POLICY_VERSION = 'domain-boundary-v1';
export class DrainageRejection extends BadRequestException {
constructor(
public readonly reasonCode: string,
reason: string,
) {
super({ code: reasonCode, message: reason });
}
}
export type DrainageTarget = { key: string; category: string; value: string; text: string; start: number; end: number };
export type DrainageMaterial = {
id: string;
url: string;
auditStatus: string;
materialVersion: number;
reportTasks: Array<{ id: string; channelId: string; carrier: string | null; status: string }>;
};
export type DrainageAssessment = {
version: string;
evaluatedAt: string;
targets: Array<DrainageTarget & { materialIds: string[] }>;
materials: DrainageMaterial[];
allowedChannelIds: string[] | null;
reasonCode: string | null;
reason: string | null;
};
export function drainageHost(raw: string) {
const value = raw
.normalize('NFKC')
.replace(/[\u200B-\u200D\u2060\uFEFF]/gu, '')
.replace(/[。。]/g, '.')
.trim();
try {
const parsed = new URL(/^[a-z]+:\/\//i.test(value) ? value : `https://${value}`);
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
const host = parsed.hostname.toLowerCase().replace(/\.$/, '');
if (isIP(host)) return host;
const domain = parse(host, { allowPrivateDomains: true });
return domain.domain && domain.isIcann !== false ? host : domain.domain && domain.isPrivate ? host : null;
} catch {
return null;
}
}
export function normalizeDrainagePhone(value: string) {
return normalizeContent(value, 'landline').text.replace(/[()]/g, '');
}
export function materialMatches(target: DrainageTarget, raw: string) {
if (target.category !== 'url') return normalizeDrainagePhone(raw) === target.value;
const host = drainageHost(raw);
if (!host || !(target.value === host || (!isIP(host) && target.value.endsWith(`.${host}`)))) return false;
// Existing path-specific material does not silently authorize unrelated paths.
const normalizedRaw = normalizeContent(raw, 'url').text.trim();
const registered = new URL(/^[a-z]+:\/\//i.test(normalizedRaw) ? normalizedRaw : `https://${normalizedRaw}`);
if (registered.pathname !== '/' || registered.search) {
const candidate = new URL(/^[a-z]+:\/\//i.test(target.text) ? target.text : `https://${target.text}`);
return (
(candidate.pathname === registered.pathname ||
candidate.pathname.startsWith(`${registered.pathname.replace(/\/$/, '')}/`)) &&
(!registered.search || candidate.search === registered.search)
);
}
return true;
}
export function drainageTargets(content: string, matches: DrainageDetectionMatch[]) {
const urls: DrainageTarget[] = [];
const normalized = normalizeContent(content, 'url');
for (const match of matches.filter((item) => item.category === 'url')) {
let start = normalized.sourceStarts.findIndex((offset) => offset >= match.start);
let end = normalized.sourceEnds.findIndex((offset) => offset >= match.end) + 1;
if (start < 0 || end <= 0) throw new ServiceUnavailableException('引流识别位置无效');
// Extend the entire URL token, including suffix labels, userInfo and query.
const token = /[a-z0-9:/?&=.%_+@#~!$*()[\]-]/i;
while (start > 0 && token.test(normalized.text[start - 1])) start--;
while (end < normalized.text.length && token.test(normalized.text[end])) end++;
const text = normalized.text.slice(start, end).replace(/[.,;!]+$/, '');
const value = drainageHost(text);
if (!value) throw new DrainageRejection('DRAINAGE_INVALID', '引流URL格式无效或不是可登记域名');
urls.push({
key: `url:${value}:${text}`,
category: 'url',
value,
text,
start: normalized.sourceStarts[start],
end: normalized.sourceEnds[end - 1],
});
}
const targets = [...urls];
for (const match of matches.filter((item) => item.category !== 'url')) {
if (urls.some((url) => match.start < url.end && match.end > url.start)) continue;
if (!['mobile', 'landline'].includes(match.category))
throw new ServiceUnavailableException('引流识别类型尚未支持发送校验');
const value = normalizeDrainagePhone(match.normalizedText);
targets.push({
key: `phone:${value}`,
category: match.category,
value,
text: match.text,
start: match.start,
end: match.end,
});
}
return [...new Map(targets.map((target) => [target.key, target])).values()];
}
export function assessDrainage(
targets: DrainageTarget[],
materials: DrainageMaterial[],
carrier?: string,
): DrainageAssessment {
let allowed: Set<string> | null = null;
const assessment: DrainageAssessment = {
version: DRAINAGE_POLICY_VERSION,
evaluatedAt: new Date().toISOString(),
targets: [],
materials: [],
allowedChannelIds: null,
reasonCode: null,
reason: null,
};
for (const target of targets) {
const matching = materials.filter((item) => item.auditStatus !== 'deleted' && materialMatches(target, item.url));
const approved = matching.filter((item) => item.auditStatus === 'approved');
assessment.targets.push({ ...target, materialIds: approved.map((item) => item.id) });
const code = !matching.length ? 'DRAINAGE_NOT_REGISTERED' : !approved.length ? 'DRAINAGE_NOT_APPROVED' : null;
if (code && !assessment.reasonCode) {
assessment.reasonCode = code;
assessment.reason = `引流信息“${target.text.slice(0, 160)}${!matching.length ? '未在当前签名下添加' : '尚未审核通过'}`;
}
const channels = new Set(
approved.flatMap((item) =>
item.reportTasks
.filter((task) => task.status === 'approved' && (!task.carrier || !carrier || task.carrier === carrier))
.map((task) => task.channelId),
),
);
allowed =
allowed === null
? channels
: new Set<string>(Array.from(allowed as Set<string>).filter((id: string) => channels.has(id)));
}
const used = new Set(assessment.targets.flatMap((target) => target.materialIds));
assessment.materials = materials
.filter((item) => used.has(item.id))
.map(({ id, url, auditStatus, materialVersion, reportTasks }) => ({
id,
url,
auditStatus,
materialVersion,
reportTasks,
}));
assessment.allowedChannelIds = allowed === null ? null : [...allowed];
if (targets.length && allowed?.size === 0 && !assessment.reasonCode) {
assessment.reasonCode = 'DRAINAGE_CHANNEL_NOT_APPROVED';
assessment.reason = '当前签名下的全部引流信息没有共同报备通过的通道';
}
return assessment;
}
export async function evaluateMessageDrainage(
prisma: PrismaService,
message: {
id: string;
content: string;
tenantId?: string | null;
applicationId?: string | null;
signatureId?: string | null;
},
carrier?: string,
materials?: DrainageMaterial[],
fresh = false,
) {
let detected;
try {
detected = await detectDrainageContent(prisma, message.content, fresh);
} catch (error) {
throw new ServiceUnavailableException('引流检测暂不可用', { cause: error });
}
const detection = detected.drainageDetection as unknown as {
matches: DrainageDetectionMatch[];
truncated: boolean;
ruleCount: number;
};
if (detection.truncated || detection.ruleCount === 0)
throw new ServiceUnavailableException('引流检测不完整,暂不能发送');
let targets: DrainageTarget[] = [];
let invalid: DrainageRejection | undefined;
try {
targets = drainageTargets(message.content, detection.matches);
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
invalid = error;
}
const rows =
materials ??
(targets.length && message.signatureId
? await prisma.smsDrainageInfo.findMany({
where: {
signatureId: message.signatureId,
tenantId: message.tenantId ?? '',
applicationId: message.applicationId ?? '',
auditStatus: { not: 'deleted' },
},
include: {
reportTasks: {
where: { reportType: 'drainage', signatureId: message.signatureId, tenantId: message.tenantId ?? '' },
},
},
})
: []);
const assessment = assessDrainage(targets, rows, carrier);
if (invalid) {
assessment.reasonCode = invalid.reasonCode;
assessment.reason = invalid.message;
assessment.allowedChannelIds = [];
}
// Append-only evidence survives later approval changes and subsequent routing attempts.
await prisma.smsDrainageDecision.create({
data: { messageRecordId: message.id, snapshot: JSON.parse(JSON.stringify(assessment)) },
});
await prisma.smsMessageRecord.update({
where: { id: message.id },
data: {
...detected,
drainageGate: JSON.parse(JSON.stringify(assessment)),
drainageInfoId:
assessment.targets.length === 1 && assessment.targets[0].materialIds.length === 1
? assessment.targets[0].materialIds[0]
: null,
},
});
if (assessment.reasonCode) throw new DrainageRejection(assessment.reasonCode, assessment.reason!);
return assessment;
}