site logo

Marico's space

游戏活动通知:如何阻止畸形 JSON 邮件 API 请求

前端技术 2026-10-05 17:34:48 8

最近在游戏项目里折腾密码重置邮件通知,踩了几个坑,这篇把问题说清楚。核心思路其实不复杂:在调用任何邮件API之前,先在本地做一个验证和渲染的边界。JSON只解析一次,缺少或多了模板变量就拒绝,用户可控的值做转义,渲染出精确版本的模板,然后在投递之前记录一条不含token的证据事件。以游戏账号的密码重置为例,这是把一个模糊的400错误变成本地可复现结果的最小复杂度方案,同时保留了系统尝试发送的证据。

流程很短:游戏账号服务生成一个带短时效的一次性重置token,把类型化的数据传给通知边界,然后收到要么是精确的验证错误,要么是渲染好的消息加上一条证据记录。只有经过验证的结果才会到达投递适配器。预览功能用的是和生产完全相同的渲染函数,所以不会悄悄接受不同的变量集合。

邮件API应该如何处理畸形JSON事件通知?

HTTP 400只表示接收方服务器认为请求无效,但不告诉你哪个边界出了问题。在这个工作流里,三种失败经常混成一个响应:外层请求不是有效的JSON、JSON语法有效但缺少必需的变量、或者渲染出的HTML结构有效但显示了不安全或非预期的内容。

把这几个类别分开处理。截断的JSON字符串应该在解析阶段失败。缺少expiresAt的payload应该在schema验证阶段失败。包含标记的用户显示名在渲染后应该保持为纯文本。如果这三者都委托给远程邮件API,反馈会延迟到达,而且在不同的投递适配器之间可能有差异。

在本地失败。

还有一个陷阱。JavaScript的对象插值可以把undefined变成可见文本而不抛出异常,所以预览可能看起来表面完整,但重置说明实际上是坏的。正确的验收条件更严格:每个必需键都存在且类型正确,没有未预期的键越过边界,过期时间仍在未来,而且渲染器对所有不可信文本只有一条转义规则。

在调用任何投递API之前先建立边界

下面的TypeScript文件可以在启用了TypeScript执行的当前Node.js环境中运行。只使用了Node API。示例选择十分钟过期作为应用策略,而不是通用的安全常量;重要的属性是发行时间和过期时间被明确记录,并且由账号服务在token被兑换时强制执行。

import { createHash, randomBytes } from "node:crypto"; type ResetVariables = { playerName: string; resetUrl: string; expiresAt: string;
}; type RenderEvidence = { eventType: "password_reset.rendered"; templateId: "password-reset"; templateVersion: 3; occurredAt: string; variableNames: Array<keyof ResetVariables>; renderedSha256: string; outcome: "accepted";
}; const requiredKeys = ["playerName", "resetUrl", "expiresAt"] as const; function parseVariables(rawJson: string): ResetVariables { let value: unknown; try { value = JSON.parse(rawJson); } catch (error) { const detail = error instanceof Error ? error.message : "unknown parse error"; throw new Error(`payload_json_invalid: ${detail}`); } if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error("payload_shape_invalid: expected an object"); } const record = value as Record<string, unknown>; const unknownKeys = Object.keys(record).filter( (key) => !requiredKeys.includes(key as keyof ResetVariables), ); if (unknownKeys.length > 0) { throw new Error(`payload_keys_unknown: ${unknownKeys.join(",")}`); } for (const key of requiredKeys) { if (typeof record[key] !== "string" || record[key].length === 0) { throw new Error(`template_variable_invalid: ${key}`); } } const variables = record as ResetVariables; const resetUrl = new URL(variables.resetUrl); if (resetUrl.protocol !== "https:") { throw new Error("reset_url_invalid: HTTPS is required"); } const expiryMs = Date.parse(variables.expiresAt); if (!Number.isFinite(expiryMs) || expiryMs <= Date.now()) { throw new Error("expiry_invalid: expected a future ISO timestamp"); } return variables;
} function escapeHtml(value: string): string { return value.replace( /[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;", })[character]!, );
} function renderPasswordReset(variables: ResetVariables): string { const playerName = escapeHtml(variables.playerName); const resetUrl = escapeHtml(variables.resetUrl); const expiresAt = escapeHtml(variables.expiresAt); return `<!doctype html>
<html lang="en"> <body> <p>Hello ${playerName},</p> <p>A password reset was requested for your game account.</p> <p><a href="${resetUrl}">Reset your password</a></p> <p>This link expires at ${expiresAt}.</p> <p>If you did not request this, you can ignore this message.</p> </body>
</html>`;
} function prepareResetEmail(rawJson: string): { html: string; evidence: RenderEvidence;
} { const variables = parseVariables(rawJson); const html = renderPasswordReset(variables); const occurredAt = new Date().toISOString(); return { html, evidence: { eventType: "password_reset.rendered", templateId: "password-reset", templateVersion: 3, occurredAt, variableNames: [...requiredKeys], renderedSha256: createHash("sha256").update(html).digest("hex"), outcome: "accepted", }, };
} const issuedAt = new Date();
const expiresAt = new Date(issuedAt.getTime() + 10 * 60 * 1000);
const token = randomBytes(32).toString("base64url");
const rawJson = JSON.stringify({ playerName: "Avery & Co.", resetUrl: `https://accounts.example/reset?token=${token}`, expiresAt: expiresAt.toISOString(),
}); const prepared = prepareResetEmail(rawJson);
console.log(prepared.html);
console.log(prepared.evidence);

在本地运行这个文件,检查两个输出。然后把expiresAt改成expiry,从一个字面量JSON fixture里删掉最后的花括号,再把playerName设成<img src=x onerror=alert(1)>。这三个测试应该分别产生一个未知键错误、解析错误、以及HTML中被转义的文本。不需要网络请求就能诊断这些问题。

示例有意不把token、收件人地址、原始变量或渲染后的正文放进证据对象。SHA-256摘要以后可以证明两条保留的渲染产物是相同的,但仅靠摘要不能证明投递成功,也不能证明用户看到了什么。投递接受、退信处理、token兑换和账号变更需要各自的事件和保留规则。

让预览和生产共用一个渲染器

预览路由只有在执行生产路径中的parseVariables和renderPasswordReset时才有价值。第二个"友好"的预览模板会制造虚假的自信循环:设计师审核的是一个产物,而用户收到的是另一个。把投递适配器放在渲染器的下游,给它传递一个已经准备好的主题、HTML正文、收件人和幂等键。

这个边界也保护了可移植性。投递系统在不同请求信封和错误正文上有差异,但应用不应该让这些差异定义它的模板契约。窄适配器可以把准备好的消息转换成提供商请求,并把结果规范化为接受、暂时失败或永久拒绝。领域事件在适配器变更时保持稳定。不要自动重试400。同一个无效payload在第二次尝试时不太可能变好,而重试可能把原始证据埋在重复噪音下面。重试决策属于规范化的结果:畸形输入和策略拒绝是永久性的;超时和明确为临时的服务器响应可能是可重试的,需要稳定的幂等键和有界的策略。对于合规证据,记录状态转换而不是一条过大的日志行。有用的链条包含请求关联ID、模板ID和不可变版本、验证结果、渲染摘要、投递适配器结果和时间戳。不放敏感信息和完整的重置URL。证据存储的访问权限应该比普通应用日志更窄,保留期应该遵循游戏及其玩家实际适用的义务,而不是任意的"保留一切"默认值。这种方法有一个真实的局限:拥有验证和渲染意味着拥有模板版本迁移、转义测试和预览界面。当非工程团队必须在外系统内独立编辑和发布模板时,这就不适用了。这种情况下,在本地保留类型化的事件契约,但使用那个系统的预览和严格变量验证作为渲染权威,然后在证据链中捕获其不可变的模板标识符和结果。权衡是减少渲染控制,换取应用不需要构建的编辑工作流。

这个边界很重要。

测试真正重要的失败模式

从确定性fixture开始。一个有效的fixture应该渲染到一个已批准的快照。分离的负面fixture应该覆盖截断的JSON、期望对象但收到数组、缺少键、空字符串、未知键、非HTTPS URL、无效时间戳、过期时间戳,以及每个用户可控字段中的HTML元字符。快照是审查变更的证据,而不是断言的替代品:还要验证输出中不包含原始的<script、没有字面的undefined,以及证据记录中没有重置token。

然后在不发邮件的情况下测试适配器契约。断言准备好的消息映射到预期的远程信封,并且远程400响应变成永久失败并带有消毒的诊断。集成测试可以使用受控邮箱,但它们应该证明一组更小的事实:接受的请求使用了预期的模板版本,链接保留了查询参数,投递的MIME消息具有预期的文本和HTML部分。

渲染相比网络往返是廉价的,所以先在本地拒绝。这也是成本优化的路径:无效事件不消耗投递尝试或重试容量。按原因码和模板版本计量验证失败,但不要把变量值放进指标标签。部署后template_variable_invalid的突然上升直接指向生产者契约不匹配。

带着以证据为中心的运维规则上线

部署前,指定变量契约的所有权,在代码中固定模板版本,并与安全和合规负责人一起审查证据字段。在预发环境,通过与生产相同的可执行路径重放有效和无效fixture。上线时,分别对验证失败和适配器永久拒绝告警;它们需要不同的响应。事件期间,按关联ID搜索,比较记录的模板版本和渲染摘要,检查消毒后的适配器响应而不暴露重置URL。

决策规则很直接:除非精确的生产渲染器可以在本地生成完整的产物和不含秘密的证据事件,否则不发送。这本身不能证明合规,但它使每个声明都可测试:哪个契约通过了,哪个模板渲染了,何时发生的,以及投递边界报告了什么。

延伸阅读

  • RFC 8259,JavaScript对象表示法(JSON)数据交换格式
  • OWASP 忘记密码速查表
  • OWASP 跨站脚本防护速查表
  • Node.js加密文档
  • 阿里云邮件推送文档
  • 短信字符限制和分段