从原型到上线的安全检查清单
从原型到上线的安全检查清单
原型能运行,上线还差一组边界
Node.js、Next.js、Nuxt 等方案让页面渲染与 API 放在相近的工程里,但服务端代码仍要按后端入口对待。浏览器传来的 URL、对象 ID、富文本和请求头都是外部输入;仓库位置接近,不会让这些输入自动可信。
SSR/API Routes 增加了服务端请求入口。若服务端直接请求用户提供的 URL,可能引入 SSRF 风险;域名解析、重定向和内网地址都应进入出站策略。
WAF 可以作为附加防护,SSR 渲染层和 Node.js 运行时仍需完成参数校验、对象授权与出站请求控制。上线检查的目的,是确认这些判断发生在实际副作用之前,并且拒绝路径也能被观察和复验。
需要逐项检查的入口
1. SSR 渲染层的反射型与存储型 XSS
React、Vue 等框架在普通文本位置会进行转义,但dangerouslySetInnerHTML、v-html和手工模板属于逃生口。若产品确实允许用户提交 HTML,应使用维护中的 HTML sanitizer,并限制允许的标签和属性。清洗后的内容不要再交给会修改字符串的未知库;若只需显示文本,继续使用框架默认文本渲染更简单。
2. SSRF (Server-Side Request Forgery,服务端请求伪造)
链接预览、OpenGraph 抓取和图片代理会让服务器访问用户提供的地址。只检查 URL 字符串不够:主机名解析后的地址、IPv4 与 IPv6、重定向后的每一跳和实际连接目标都要受限制。能够列出允许域名的场景优先采用白名单,并关闭客户端自动跟随未经重新校验的重定向。
3. 对象级授权失效 (BOLA / IDOR)
API 收到documentId等对象标识时,要验证当前身份是否有权对该对象执行当前动作。只比较“对象所有者是否等于当前用户”只能覆盖一部分模型;共享文档、组织角色和委托访问需要按业务授权规则判断。对象标识不可猜也不能替代授权。
4. 环境变量与 API 密钥透传漏洞
框架通常约定某些前缀的变量可进入客户端包。命名扫描只能发现明显问题,更可靠的办法是维护允许公开的变量清单,并检查最终构建产物。服务端密钥通过部署平台受控注入,不写进仓库、示例和错误日志;怀疑泄露时先轮换,不能只改变量名。
防护放在实际执行位置
出站 HTTP 应收口到统一客户端,负责协议、解析地址、重定向、超时、响应大小和网络出口策略。API 授权根据当前会话、目标资源与动作执行,不能信任请求体传来的用户 ID。公开环境变量采用允许清单,并在候选制品中复查。三类控制分别解决不同问题,不要用一个“安全中间件”标签掩盖职责。
一个 TypeScript 检查示例及其局限
下面的FullstackSafetyGateway演示富文本清洗、字面 IP 检查和简单所有者比较。它不能作为完整 SSRF 防线:主机名没有解析,IPv6 与更多保留地址未覆盖,也没有重新校验重定向和实际连接地址。assertResourceOwnership同样只适合单一所有者模型。保留这段代码是为了说明检查位置,接入应用前必须按网络和授权模型补全。
import { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import createDOMPurify from 'dompurify'; import { JSDOM } from 'jsdom'; import * as net from 'net'; import { URL } from 'url'; // 在 Node.js 环境中初始化 DOMPurify const window = new JSDOM('').window; const DOMPurify = createDOMPurify(window as any); /** * 全栈安全防爆网关 */ export class FullstackSafetyGateway { /** * 1. XSS 富文本清洗中间件 (专门防护 SSR 动态 HTML 渲染) */ public static sanitizeHtmlInput(dirtyHtml: string): string { return DOMPurify.sanitize(dirtyHtml, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'], ALLOWED_ATTR: ['href', 'target', 'title'], }); } /** * 2. SSRF 防护拦截器: 检查 URL 是否尝试访问内网 IP 或敏感域名 */ public static validateSafeUrl(inputUrl: string): { isSafe: boolean; reason?: string } { try { const parsed = new URL(inputUrl); // 仅允许 http 和 https 协议,禁止 file://, ftp://, gopher:// if (!['http:', 'https:'].includes(parsed.protocol)) { return { isSafe: false, reason: '仅支持 HTTP/HTTPS 协议' }; } const hostname = parsed.hostname; // 校验是否为 IP 地址 if (net.isIP(hostname)) { if (this.isPrivateIP(hostname)) { return { isSafe: false, reason: '🚨 拦截 SSRF 攻击: 禁止请求私有/内网 IP 地址!' }; } } // 禁止localhost与常见内网域名 if (['localhost', 'metadata.google.internal'].includes(hostname.toLowerCase())) { return { isSafe: false, reason: '🚨 拦截 SSRF 攻击: 禁止请求保留域名!' }; } return { isSafe: true }; } catch (e) { return { isSafe: false, reason: '非法 URL 格式' }; } } /** * 判断是否为私有/内网 IP */ private static isPrivateIP(ip: string): boolean { // 匹配 127.0.0.1, 10.x.x.x, 172.16.x.x - 172.31.x.x, 192.168.x.x, 169.254.x.x (Metadata) const privatePattern = /^(127\.0\.0\.1|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}|169\.254\.\d{1,3}\.\d{1,3})$/; return privatePattern.test(ip); } /** * 3. BOLA 对象级越权防护断言 */ public static assertResourceOwnership( currentUserId: string, resourceOwnerId: string ): void { if (currentUserId !== resourceOwnerId) { throw new Error('[Security Violation 403] 越权访问: 您无权对该资源进行读取或修改!'); } } } // ==================== 使用示例与测试 ==================== if (require.main === module) { // 测试 SSRF 防护 const dangerousUrls = [ 'http://127.0.0.1:8080/admin', 'http://169.254.169.254/latest/meta-data/', 'https://api.github.com/users', ]; console.log('🛡️ 正在进行 SSRF 安全过滤检测:'); dangerousUrls.forEach(url => { const check = FullstackSafetyGateway.validateSafeUrl(url); if (!check.isSafe) { console.error(`❌ [拦截成功] URL: ${url} -> 原因: ${check.reason}`); } else { console.log(`✅ [安全通过] URL: ${url}`); } }); // 测试 XSS 清洗 const dirtyScript = `<script>alert("XSS Vulnerability")</script><p>Hello World <a href="javascript:stealCookies()">Click Me</a></p>`; const cleanHtml = FullstackSafetyGateway.sanitizeHtmlInput(dirtyScript); console.log('\n🛡️ XSS 清洗前后对比:'); console.log('清洗前:', dirtyScript); console.log('清洗后:', cleanHtml); }上线前逐项核对
| 校验分类 | 核心检查项 (Checklist) | 确定性防范措施 |
|---|---|---|
| 密钥泄漏防护 | .env、客户端变量与构建产物中没有秘密 | 公开变量允许清单、制品扫描和轮换预案 |
| SSRF 风险控制 | 所有服务端出站请求经过统一策略 | 校验协议、解析地址、重定向和实际网络出口 |
| XSS 防护 | 搜索dangerouslySetInnerHTML、v-html与手工模板 | 文本使用安全输出位置,确需 HTML 时清洗并及时更新依赖 |
| 对象级授权 | 每个接收对象 ID 的接口按动作校验 | 使用服务端身份与业务策略查询可访问对象,并测试拒绝路径 |
| CORS 与 Header | 来源、凭据和响应头符合实际部署 | 按调用方设置 CORS,CSP 作为附加层并在报告模式下验证 |
| 日志与错误 | 响应和日志不包含令牌、内部地址或完整输入 | 使用请求标识、错误阶段与脱敏摘要定位 |
用反例完成验收
安全验收不只跑正常请求。准备无权对象 ID、过期会话、富文本危险属性、主机名解析到受限地址和多次重定向等受控样本,确认请求在副作用前停止。测试在隔离环境进行,不访问真实内网资源,也不把生产凭据写进样例。
发现问题时保留配置版本、请求标识和修复后的回归结果。依赖、网络或授权模型变化后重跑相关场景。原型到上线的差别,不是多装一个安全包,而是每个外部入口都有明确的允许条件、拒绝行为和维护负责人。
