☰
Web安全基础知识:从信任边界到上线自查的工程实践
2026/11/3 23:37:06 网站建设 项目流程

“Web安全基础知识整理”这个标题乍一看像是给刚入行的新人准备的一份学习笔记,但我在实际项目里摸爬滚打这些年,越来越觉得它其实是每一个写过线上代码、维护过公网服务的人都该反复过一遍的东西。它讲的不是什么高深算法,而是一堆看起来朴素、但一旦漏掉就会让你半夜被叫起来处理线上事故的基本功:输入怎么校验、输出怎么转义、会话怎么管理、权限怎么判断、文件怎么存、日志怎么留。这篇整理面向三类人:刚接触服务端开发、还没系统学过安全的新人;写了几年业务代码、但对漏洞成因只停留在“听说过”阶段的开发者;以及需要给团队做安全规范、却缺一份能落地清单的技术负责人。我不会给你堆砌名词,而是把每一类问题的成因、可复现的代码、以及我在真实项目里踩过的坑摊开讲,让你看完就能对照自己的项目改。

1. 先把边界想清楚:Web安全基础知识到底在防什么

很多人学安全的第一反应是背漏洞名字:SQL注入、XSS、CSRF、越权、文件上传……背完一圈发现还是不知道自己的系统哪里有问题。问题出在顺序上——先背招式,再找敌人,效率极低。我习惯反过来:先把系统的“信任边界”画出来,再去想边界上会发生什么。

1.1 用“信任边界”替代“漏洞清单”的思考方式

所谓信任边界,就是数据从“不可信”流向“可信”的那条线。浏览器提交的表单、URL 里的查询参数、请求头里的 Referer 和 User-Agent、上传的文件、第三方回调的内容,全都站在不可信那一侧;数据库、文件系统、命令执行环境、模板渲染引擎,全都站在可信那一侧。安全的本质工作,就是守住这条线上的每一个卡口。

这么一想,很多漏洞的名字就自动归位了:SQL注入是“不可信数据直接进了 SQL 解析器”,XSS 是“不可信数据直接进了 HTML 解析器”,命令注入是“不可信数据进了 shell”,SSRF 是“用户控制了服务端发起请求的目标地址”,越权是“服务端信任了客户端传来的身份标识”。它们全是同一件事的不同变体——数据跨过了边界却没有被重新解释。

我在给团队做内部分享时常用一个类比:把不可信数据想象成别人递给你的一杯水。你可以喝(使用它),但你必须先自己换个杯子(重新编码/转义/参数化),而不是直接把对方手里的杯子凑到嘴边。换杯子这个动作,在技术上就是“在正确的上下文里做正确的处理”。

注意:这里说的“处理”不是把危险字符删掉。删字符是最容易出事的做法,因为它既可能破坏正常业务数据(用户名字里带引号很正常),又可能被绕过(删除一次后拼接出新的危险字符)。正确做法永远是“编码”或“参数化”,让数据保持原样但失去被解析为代码的能力。

1.2 先把攻击面列出来,再谈防护

我接手一个陌生系统时,第一件事不是看代码,而是列攻击面。具体做法是打开抓包工具,把系统所有接口过一遍,按下面的维度打勾:

攻击面类型典型入口常被忽略的点
用户输入表单、查询参数、JSON body、请求头自定义 Header、Cookie 里的业务字段
文件上传接口、导入功能、头像裁剪文件名、文件内容、压缩包解压
服务端外连图片抓取、webhook、回调、代理下载重定向跟随、DNS 解析
身份与权限登录、注册、找回密码、邀请接口级鉴权、横向越权
第三方依赖前端库、后端包、镜像间接依赖、版本锁定
配置与部署环境变量、调试开关、错误页生产环境残留的测试接口

列完这张表你会发现,防护工作从“漫无边际”变成了“逐项打勾”。我一般会把这张表存成团队共享文档,每上线一个新功能就补一行,半年下来它就是一份贴合自己业务的资产清单,比任何通用清单都好用。

1.3 新手最容易走偏的三个方向

第一个方向是追求“绕过技巧”而不打基础。网上很多文章讲怎么绕过某个过滤,看着很爽,但你如果连为什么要过滤都没搞懂,学到的只是招式,换个场景就废了。

第二个方向是把安全全押在某个中间件或设备上。我见过不少团队觉得“前面有防护设备了,后端随便写”,结果一旦有内网访问或者设备规则没覆盖到,问题照样出。防护设备是加分项,不是替代项,代码里的处理才是根本。

第三个方向是只在开发阶段想安全,上线后就不管了。实际上线后才是真正的考验:依赖库会爆漏洞、配置会被人误改、日志会暴露敏感信息。安全是个持续动作,不是一次性任务。

2. 输入处理:注入类漏洞的根子究竟在哪

注入类的漏洞之所以常年霸榜,不是因为技术难,而是因为它太容易写出来了。一段随手拼出来的字符串,功能测试完全正常,只有等你传入一个特殊字符时才会露馅。我下面把三类最常见的注入拆开讲,重点讲“为什么参数化能根治”,而不是“记得用参数化”这种口号。

2.1 SQL注入:拼接字符串的那一刻就已经输了

先看一段我早期写过的、现在想起来还脸红的代码:

// 错误示范:直接拼接 $sql = "SELECT * FROM users WHERE name = '" . $_GET['name'] . "' AND status = 1"; $result = mysqli_query($conn, $sql);

当传入name=admin' --时,SQL 变成SELECT ... WHERE name = 'admin' -- ' AND status = 1,后半段被注释掉,条件被绕过。更狠的还能用UNION SELECT把其他表的数据带出来。

问题的根源在于:SQL 语句的结构和数据被放进同一个字符串通道里传给了数据库。数据库引擎分不清哪部分是它要执行的结构、哪部分是值。参数化查询解决的正是这个——它先把带占位符的语句结构发给数据库编译,再把值单独发过去填空,两者走不同的通道,值永远不会被当作结构解析。

// 正确写法:PDO 预处理 $stmt = $pdo->prepare('SELECT * FROM users WHERE name = ? AND status = 1'); $stmt->execute([$_GET['name']]); $rows = $stmt->fetchAll();

Java 那边同理:

String sql = "SELECT * FROM users WHERE name = ? AND status = 1"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, name); try (ResultSet rs = ps.executeQuery()) { /* ... */ } }

有三处坑我必须提醒。第一,表名、列名、排序方向不能参数化,占位符只能填值。这类动态结构必须用白名单映射,比如把前端传来的sort=create_time映射到一个固定的字段名数组里,映射不到就回退默认值,千万别直接拼进去。第二,LIKE查询要处理%和_,一般做法是先把用户输入里的这两个字符转义,再拼上你自己加的百分号。第三,IN子句要按参数个数动态生成占位符,而不是把数组implode成一个字符串塞进去。

2.2 命令注入与模板注入:换个“解析器”就是新的战场

SQL 之外,服务端还会调用 shell、调用模板引擎、调用表达式求值。这些地方的原理完全一样:只要用户数据进入了另一个解析器,就要重新考虑转义。

# 危险:用户输入的 filename 直接进 shell convert uploads/${filename} -resize 200x200 out.jpg

传入filename=1.jpg; cat /etc/passwd就直接执行了额外命令。正确的做法是避免走 shell,用语言提供的进程调用接口并传数组参数,让参数不经过 shell 解析:

import subprocess subprocess.run(["convert", f"uploads/{filename}", "-resize", "200x200", "out.jpg"], shell=False)

模板注入是近几年比较多见的类型。当模板内容本身可以由用户影响时(比如“邮件模板自定义”“报表标题支持变量”),用户就可能写入表达式被引擎执行。防护思路是:模板字符串必须来自服务端预设,用户只能填充变量值,不能影响模板结构。如果业务上确实需要用户自定义模板,那就要用沙箱化的引擎,并且对可用变量做白名单限制。

提示:判断某个位置是否需要转义,只需问一句“这段数据最终会被谁解析”。HTML 解析器、JS 引擎、SQL 引擎、shell、正则引擎、XML 解析器,各有各的规则,一套转义走天下一定会出事。

2.3 输入校验的正确姿势:白名单、长度、类型三件套

校验和转义是两件事,别混。转义解决“数据不被当代码”,校验解决“数据是不是我要的”。现实项目里两者都要有。

我一般按三层来做。第一层是类型与格式,比如手机号用正则、金额用 decimal、ID 必须是数字、日期必须能被正确解析。第二层是长度与范围,字符串限长(数据库字段长度减去余量)、数字限区间、数组限元素个数。第三层是业务合法性,比如“订单状态只能从待支付流转到已支付”,这种校验必须在服务端做,前端校验只是体验优化。

// 示例:一个相对完整的校验函数 function validateNickname(raw) { if (typeof raw !== 'string') return { ok: false, reason: 'type' }; const s = raw.trim(); if (s.length < 1 || s.length > 20) return { ok: false, reason: 'length' }; // 白名单:中英文、数字、下划线 if (!/^[\u4e00-\u9fa5A-Za-z0-9_]+$/.test(s)) return { ok: false, reason: 'charset' }; return { ok: true, value: s }; }

这里有个经验:校验失败时的错误提示要克制。不要告诉用户“你的输入包含非法字符 <”,那等于把过滤规则告诉他。统一返回“昵称格式不符合要求”就够了。这个细节很多团队不注意,实际上它是信息泄露的一种。

3. 浏览器这一侧:XSS、CSRF 与同源策略

前端安全问题里,XSS 和 CSRF 被讲得最多,但也最容易被“知道个大概”糊弄过去。我见过太多项目在这两块只做了半套,看着有防护,实际一测就穿。

3.1 XSS 的三种形态与四种输出上下文

XSS 的本质是用户数据被浏览器当成脚本执行了。分三种:存储型(数据存进库,别人访问时触发)、反射型(数据从 URL 直接回显到页面)、DOM 型(前端 JS 把不可信数据写进了危险 API)。存储型危害最大,因为它能影响所有访问者;DOM 型最容易被漏掉,因为服务端根本看不到问题。

防护的核心不是“过滤 script 标签”,而是按输出上下文做编码。同一个用户输入,放在不同位置要用不同的处理方式:

输出位置处理方式常见错误
HTML 文本HTML 实体编码& < > " '只替换了<和>
HTML 属性属性值加引号 + 实体编码属性值不加引号
JS 字符串JSON 序列化后再嵌入直接拼进var a = '...'
URL 参数encodeURIComponent直接拼在href里
CSS尽量不使用用户输入拼进style属性

前端框架帮了大忙。React、Vue 默认对插值做转义,所以只要你用{data}而不是dangerouslySetInnerHTML或v-html,基本不会出问题。但坑在于:一旦业务需要富文本,很多人就直接上v-html,等于把口子全开了。这时候必须引入 HTML 清洗库,用白名单方式只保留允许的标签和属性,并且把on*事件属性、javascript:协议全部干掉。

// 富文本清洗示例(用成熟库,别自己写正则) import DOMPurify from 'dompurify'; const clean = DOMPurify.sanitize(userHtml, { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'], ALLOWED_ATTR: ['href', 'title'], ALLOWED_URI_REGEXP: /^https?:\/\//i });

3.2 CSP 与 Cookie 属性:纵深防御的两把锁

编码是第一道防线,但现实中总有人写漏一处。这时候需要第二道锁,让即便漏了也炸不起来。

CSP(内容安全策略)就是这道锁。它通过响应头告诉浏览器“只允许加载哪些来源的脚本”。

Content-Security-Policy: default-src 'self'; script-src 'self' https://static.example-cdn.test; object-src 'none'; base-uri 'self'; frame-ancestors 'self'

上线 CSP 有个现实问题:老项目里往往有大量内联脚本和eval,一上严格策略页面就白屏。我的做法是分两步,先上Content-Security-Policy-Report-Only,收集一段时间违规报告,把内联脚本逐步抽成外部文件,再切到强制模式。这个过程通常要一到两个月,但收益很实在。

Cookie 方面,几个属性必须配齐:

属性作用建议
HttpOnly禁止 JS 读取会话 Cookie 必开
Secure仅 HTTPS 传输全站 HTTPS 后必开
SameSite限制跨站携带会话类用Lax或Strict
Path/Domain限制作用范围尽量收窄,别用顶级域

注意:SameSite=None必须配合Secure使用,而且它会把 Cookie 暴露给跨站请求,只在确实需要跨站嵌入的场景(比如被第三方页面 iframe 引用)才开,并且要配合其他手段。

3.3 CSRF:为什么加了 Token 还是会被打

CSRF 的原理是浏览器会自动带上目标站点的 Cookie,攻击者只需要诱导受害者访问一个页面,就能以受害者身份发起请求。很多人以为“我用了 POST 就安全了”,其实不对,表单可以自动提交。

标准的防护是 Token 机制:服务端下发一个随机 Token,存在会话里,表单提交时带上,服务端比对。要点有三个,缺一不可:Token 必须不可预测(用密码学随机数)、必须绑定会话、必须一次性或有时效。我见过只在页面渲染时生成一次、永不过期的实现,那基本等于没做。

// 简化示意:Token 校验 String sessionToken = (String) session.getAttribute("csrfToken"); String requestToken = request.getParameter("_csrf"); if (sessionToken == null || !MessageDigest.isEqual( sessionToken.getBytes(StandardCharsets.UTF_8), requestToken == null ? new byte[0] : requestToken.getBytes(StandardCharsets.UTF_8))) { response.sendError(403); return; }

注意这里用的是MessageDigest.isEqual而不是equals,是为了避免时序侧信道,虽然对 CSRF 来说影响有限,但养成习惯没坏处。

第二个坑是:只保护了表单,忘了 AJAX 和 JSON 接口。JSON 接口同样需要 Token,可以放在自定义请求头里。第三个坑是:只保护了写操作,忘了读接口。有些读接口会返回敏感数据,被 CSRF 读到也是泄露。所以我的原则是:所有改变状态的接口必须校验,返回敏感数据的接口也要校验。

4. 认证、会话与权限:最容易被低估的一层

说实话,比起注入和 XSS,我更担心权限问题。因为注入和 XSS 有成熟的工具能扫出来,而越权往往要靠人工一点点试,业务逻辑稍复杂一点就容易漏。

4.1 密码存储:别再用 MD5 了,真的

这个话题讲了十几年还在讲,说明确实有人没改。MD5、SHA1 这类快速哈希绝对不能用于密码存储,原因很简单:现代显卡每秒能算几十亿次,一个八位密码的哈希几分钟就能撞出来。加盐能防彩虹表,但防不住暴力破解。

正确选择是专门的密码哈希算法,比如 bcrypt、scrypt、Argon2。它们的共同特点是“故意慢”,而且成本可调。

import bcrypt # 生成:cost=12 时单次约 200~300ms(视机器而定) hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(rounds=12)) # 校验 if bcrypt.checkpw(password.encode('utf-8'), hashed): pass # 通过

这里有个参数计算的实际问题。cost=12 意味着 2 的 12 次方轮迭代,单次耗时几百毫秒。对一个登录接口来说这没问题,但如果你的登录 QPS 峰值是 50,那理论上需要 50 × 0.25 = 12.5 个 CPU 核心专门用来算哈希。所以 cost 不是越高越好,一般取“单次 200~300ms”作为基准,然后根据压测结果决定要不要上独立的认证服务。我见过有团队直接上 cost=14,结果压测时登录接口全线超时,最后又降回来。

另外两个细节:密码比对要用恒定时间比较;不要在任何日志、错误信息、埋点里记录明文密码,包括“密码错误:xxx”这种调试残留。

4.2 越权:接口鉴权必须落在服务端每一个入口

越权分两种。水平越权是同级别用户之间的越权,比如 A 用户通过改 ID 看到了 B 用户的订单;垂直越权是低权限用户执行了高权限操作,比如普通用户访问了管理接口。

根因只有一个:服务端相信了客户端传来的身份或资源标识,却没有做归属校验。

// 错误示范:只校验了登录,没校验归属 Order order = orderMapper.selectById(request.getOrderId()); return order; // 正确:加上归属校验 Order order = orderMapper.selectById(request.getOrderId()); if (order == null || !order.getUserId().equals(currentUserId)) { throw new ForbiddenException(); }

我的经验是把这件事做成“强制约定”而不是“靠自觉”。具体做法是在权限层做统一拦截:所有涉及用户资源的查询,DAO 层方法签名强制带上ownerId参数,从框架层面杜绝“忘了传”的可能。这个改动初期会有点痛,但一旦落地,新写的代码就不可能漏。

垂直越权则是接口级鉴权的问题。我的建议是按“默认拒绝”设计:新接口默认不可访问,必须显式声明所需权限,声明了才放行。这样哪怕有人忘了配置,结果也是 403 而不是敞开门。

4.3 登录防护:限速、锁定与账号枚举

登录接口是攻击者的第一站。三件事必须做。

第一是限速。我通常按两个维度限:单 IP 和单账号。单 IP 做 5 次/分钟,单账号做 10 次/小时,触发后不是直接封死,而是做指数退避(重试间隔翻倍)并要求输入图形验证码。这里给个具体的阈值推导:正常用户输错密码极少超过 3 次,5 次的窗口足够容纳手误;而暴力破解一天要试几万次,5 次/分钟意味着一天最多 7200 次,再叠加账号锁定,成本就上去了。

第二是防账号枚举。登录失败时,无论账号不存在还是密码错误,都返回同一句“账号或密码不正确”。注册、找回密码、邀请等接口同理,否则攻击者能批量判断哪些手机号/邮箱注册过。

第三是登录态管理。会话 ID 必须在登录成功后重新生成(防止会话固定攻击),退出登录时服务端要真正失效会话,而不是只清 Cookie。多端登录要不要踢掉旧会话,取决于业务,但至少在改密码后必须强制所有会话失效。

5. 文件与服务端请求:两个容易“看起来做了防护”的领域

这两块的共同特点是:防护措施看起来很完整,但细节一漏就等于没做。

5.1 文件上传:扩展名、内容、存储位置三处都得管

我见过的最典型的错误实现,是只校验了前端传的Content-Type。这个头是客户端随便改的,一点意义都没有。正确的做法分四步。

第一步,扩展名白名单。只允许业务真正需要的类型,比如头像只放jpg/jpeg/png/webp。要注意大小写和多重扩展名,a.php.jpg这种必须被识别成最后一段或被直接拒绝。

第二步,内容类型嗅探。读文件头几个字节判断真实类型,比如 JPEG 以FF D8 FF开头,PNG 以89 50 4E 47开头。这一步能挡住“改了扩展名的脚本文件”。

第三步,重命名与隔离存储。文件名用随机串加白名单扩展名,存到 Web 根目录之外,或者存在对象存储里只提供带签名的临时访问链接。这样即便有漏网之鱼也执行不了。

第四步,解压与图片处理要小心。压缩包解压必须防路径穿越(../../),并且限制解压后的总大小和文件数量,防压缩炸弹。图片处理库要及时更新,历史上多个图像处理库都出过解析漏洞。

// 扩展名白名单示意 $allowed = ['jpg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp']; $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if (!isset($allowed[$ext])) { throw new Exception('类型不允许'); } $newName = bin2hex(random_bytes(16)) . '.' . $ext;

提示:如果业务允许上传 SVG,要额外小心。SVG 本质是 XML,可以内嵌脚本,直接渲染可能导致 XSS。比较稳妥的处理是先转成位图再展示。

5.2 SSRF:用户控制服务端请求目标就是灾难

图片抓取、URL 预览、webhook 回调、代理下载,这些功能的共同点是“服务端根据用户提供的地址去请求”。一旦没有约束,攻击者就能让服务器去访问内网地址,读取内部服务的数据。

防护要分几层做。首先是协议白名单,只允许http和https。其次是地址黑名单,把私网段和保留地址全排掉:

网段说明
127.0.0.0/8本机回环
10.0.0.0/8私网 A 类
172.16.0.0/12私网 B 类
192.168.0.0/16私网 C 类
169.254.0.0/16链路本地,云环境元数据常见位置
0.0.0.0/8、100.64.0.0/10保留与运营商级 NAT

但只做黑名单不够,因为存在 DNS 重绑定和重定向绕过。可靠的做法是:先把域名解析成 IP,校验 IP,然后用这个 IP 去连接,并且在连接过程中不跟随重定向,或者每次重定向都重新校验。用现成的 HTTP 客户端时要显式关掉自动重定向。

还有个容易被忽略的点:抓取到的内容回显给前端时,同样要考虑 XSS;抓取到的文件大小要限制,防止把磁盘写满。

5.3 反序列化与第三方依赖:看不见的入口

反序列化漏洞的原理是:某些语言在反序列化时会自动调用对象的特定方法,攻击者可以构造一个调用链(gadget chain),让这些方法最终执行危险操作。Java 的readObject、PHP 的unserialize、Python 的pickle都是重灾区。

我的建议很直接:能用 JSON 就别用原生序列化格式。如果确实要用,至少加上类型白名单,只允许反序列化已知的类。

依赖安全则是另一条线。现代项目动辄几百个间接依赖,任何一个出问题都可能影响你。我现在的习惯是:项目初始化就配好依赖扫描,锁文件必须提交,CI 里跑一次漏洞扫描,高危包直接卡住合并。升级策略上,我倾向“小步快跑”,每个月固定花半天升级一批,而不是攒到年底大版本一起动,那时候改动量太大,没人敢升。

6. 传输、配置与运维:上线之后才真正开始

代码写对了只是一半。传输层、配置项、日志与监控,这三块决定了你在出事后能不能快速发现和止损。

6.1 HTTPS 与安全响应头

全站 HTTPS 是底线,没有例外。配置上有几个要点:只启用 TLS 1.2 及以上版本,禁用已知不安全的加密套件,开启 HSTS 让浏览器记住“以后只走 HTTPS”。

Strict-Transport-Security: max-age=31536000; includeSubDomains X-Content-Type-Options: nosniff X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=()

HSTS 有个坑:max-age一旦设置,浏览器会在有效期内强制走 HTTPS,如果这时你的证书过期了,用户会直接打不开且无法跳过。所以上线前务必确认证书自动续期是好的,第一次可以先设置较短的max-age,稳定后再调大。

6.2 配置与错误处理:泄漏往往从这里开始

生产环境的配置里,有几样东西绝对不能出现:调试模式开启、详细错误堆栈返给前端、默认账号密码、数据库端口对公网开放、对象存储桶公开可读。

我排查陌生系统时,第一个动作往往是访问一个不存在的路径,看返回什么。如果返回了框架版本和堆栈,基本可以判断这个系统对错误处理不上心。正确做法是统一异常处理,对外只返回错误码和一句通用描述,详细信息写进服务端日志并带上一个跟踪 ID,方便排查时关联。

{"code":"SYS_ERROR","message":"系统繁忙,请稍后重试","traceId":"a1b2c3d4"}

6.3 日志、监控与应急响应

日志是事后追溯的唯一依据,但很多项目记得太随意。我要求至少记录这几类事件:登录成功与失败(含 IP、UA 摘要)、权限变更、敏感数据导出、后台管理操作、异常与拦截。敏感字段(密码、身份证、手机号中间位)必须打码。

日志本身的存储也要注意,别和应用同机同权限,否则一旦被拿下等于帮攻击者清理痕迹。留存时间按业务需求定,一般不少于 90 天,涉及资金类的建议更长。

监控方面,我给团队定过几条简单的告警规则,实测能覆盖大部分异常:单 IP 短时间内大量 4xx(在扫接口)、登录失败率突增、单账号异地登录、接口响应时间突增(可能是被拖库)、数据库慢查询里出现UNION或SLEEP关键字。这些规则实现成本很低,但能让你比用户更早发现问题。

7. 常见问题排查实录

这一节是我这些年处理问题的记录整理,都是实际遇到过的现象,配了当时的判断依据。

7.1 排查思路:从现象倒推入口

拿到一个问题,我一般按这个顺序走:

第一步,确认现象能复现,并且确定是哪个接口。抓包看请求,把请求参数逐个改成特殊值试。

第二步,判断数据流向。这个参数最终去了数据库、文件、模板还是 shell?不确定就去看代码,看它在哪一层被使用。

第三步,验证假设。比如怀疑 SQL 注入,就传'看报错,传1 AND 1=1和1 AND 1=2看返回差异。注意这一步必须在有授权的测试环境里做。

第四步,定位根因并修复。修复后回归验证,同时检查同类接口是否也有相同问题。这一步最容易被跳过,但价值最高——同一个开发者往往会在多处犯同样的错。

7.2 高频问题速查表

现象可能原因排查方向处理建议
页面出现莫名脚本弹窗存储型 XSS检查展示该字段的所有位置按上下文编码,加 CSP
用户能看到别人的数据水平越权检查查询是否带归属条件DAO 层强制带 ownerId
接口被批量调用缺少限速看访问日志的 IP 与频率分布网关层加限速与风控
上传文件后服务器异常上传目录可执行检查目录权限与解析配置移出 Web 根目录或关闭执行
后台能访问内部服务SSRF检查抓取类功能的地址校验协议白名单 + IP 校验 + 禁重定向
登录接口偶发超时密码哈希 cost 过高看 CPU 占用与压测结果下调 cost 或拆分认证服务
依赖扫描突然报高危上游爆漏洞看漏洞影响版本与调用链评估后升级或临时加缓解
错误页暴露堆栈调试模式未关访问不存在路径验证统一异常处理 + 关闭调试

7.3 几个踩过的坑

有个项目上线后一直报“图片上传失败”,查了半天发现是内容嗅探太严格,用户把 PNG 改名为 JPG 上传,文件头对不上被拒。后来改成“以文件头为准,扩展名按实际类型修正”,体验就好了。

还有一次,团队为了防 XSS 做了一个全局过滤器,把所有<和>都替换掉。结果富文本编辑器的内容全乱了,而且这个过滤器对属性里的引号完全没处理,照样能打。最后老老实实改回按上下文编码。

另一个坑是限速策略配得太粗,按 IP 限速导致整个公司出口 IP 的用户互相影响,早上上班高峰期集体登录失败。后来改成 IP + 账号双维度,才解决问题。

注意:所有安全测试都必须在获得明确授权的环境中进行。对第三方系统做任何探测都属于越界行为,这条线不能碰。

8. 落地清单与自动化

道理讲完,最难的是落地。我一般给团队这样安排:先做一次自查,把高危项修掉,再把能自动化的部分塞进 CI,剩下的靠定期巡检。

8.1 上线前自查清单

  • 所有 SQL 是否都走了参数化,动态表名列名是否用了白名单映射
  • 所有用户输入在输出时是否按上下文编码,富文本是否用了清洗库
  • 会话 Cookie 是否配了 HttpOnly、Secure、SameSite
  • 所有涉及资源的接口是否做了归属校验,新接口是否默认拒绝
  • 密码是否用了 bcrypt/scrypt/Argon2,比对是否恒定时间
  • 上传是否做了扩展名白名单、内容嗅探、重命名、隔离存储
  • 抓取类功能是否限制了协议和目标地址,是否关闭了自动重定向
  • 生产环境是否关闭调试,错误页是否统一
  • 安全响应头是否配齐,HSTS 是否开启
  • 日志是否记录关键操作,敏感字段是否打码

这份清单不长,但每一项都是能直接对应到代码的。我建议每季度过一遍,比看十篇综述文章管用。

8.2 把检查塞进流水线

靠人记清单不现实,能自动化的尽量自动化。我的做法是在代码提交和构建阶段加几道关卡:静态扫描查危险函数调用(字符串拼接 SQL、eval、innerHTML、pickle.loads)、依赖扫描查已知漏洞、密钥扫描查误提交的凭据、容器镜像扫描查基础镜像漏洞。

# 构建流水线片段示意 steps: - name: static-scan run: semgrep --config=p/security-audit --error - name: dependency-scan run: osv-scanner --lockfile=package-lock.json - name: secret-scan run: gitleaks detect --no-banner --redact

扫描结果的处理策略上,我的建议是:高危必堵,中危限期整改,低危记录观察。全堵会导致误报太多、大家开始习惯性忽略告警,那比不扫还糟。定期回顾误报规则,把噪音降下来,这套机制才能长期跑下去。

最后分享两个我自己一直在用的小习惯。第一个是每次写完一个涉及用户输入的功能,花两分钟问自己三句话:这个数据从哪来、经过谁、最后去了哪。这三句话能覆盖绝大多数问题。第二个是建一个自己的“事故笔记本”,每次处理完问题就记一笔现象、根因、修法,年底翻一遍,比读任何教程都实在。安全这件事没有终点,都是靠一个个具体的问题堆出来的经验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询