上一章讲逻辑漏洞时,我们已经遇到过一个很顽固的误区:页面把按钮禁用了、把价格算好了、把流程步骤藏起来了,于是大家下意识地觉得“用户应该只能这么操作”。可浏览器就在用户手里。用户能查看脚本、修改 DOM、调整本地状态,也能绕开页面直接构造请求。到了客户端安全,这件事得再往前推一步:凡是在浏览器里发生的判断,都只能算一次提示或预处理,不能自动变成可信结论。
现代前端也早就不只是“把 HTML 显示出来”。它会读取地址栏和接口响应,恢复本地缓存,与 iframe、弹窗和 WebSocket 通信,还会把统计、客服、支付等第三方脚本带进页面。每多一条数据通道,浏览器里就多一道需要说明白的边界。本章不会追求某个炫目的弹窗效果,而是练一套更耐用的方法:找出数据从哪里来、经过什么处理、最后影响哪里,再检查服务器是否重新确认,防守方是否看得见异常。
本章所有验证都只适用于有明确书面授权的系统、团队自建实验环境或专门靶场。使用隔离的浏览器配置、低权限测试账号、虚构数据和无害标记;不要读取、复制、外传或复用真实会话与用户数据。一旦动作要跨出授权的主机、账号、消息类型或数据范围,就应停止并重新确认。

第一次审计前端时,人很容易被“这是我们自己写的页面”这句话带偏。代码确实是团队发布的,但它下载到浏览器之后,运行地点已经不受服务器控制。用户可以暂停脚本、改变量、删掉遮罩层、重放网络请求,也可以让同一 origin 下的其他脚本参与执行。因此,我们应该信任服务器保存的事实和服务器做出的授权决定,而不是信任某个页面当下的样子。
说得直接一点,下面这些现象都不能单独证明安全:
disabled 或 maxlength;isAdmin: false;这些设计可以改善体验,也可以减少误操作,但用户能改变它们。真正需要问的是:即使客户端完全不配合,服务器还能不能根据当前会话、目标对象、业务状态和策略做出正确决定?
我们可以把一个页面拆成几块相邻区域:顶层文档、同源脚本、跨源 iframe、浏览器存储、后端接口、实时连接和第三方代码。每块区域都记录四件事:谁能向它写入、谁能从它读取、它能触发什么动作、动作最终由谁批准。
例如,主题偏好可以由页面写入本地存储,也可以由同源脚本读取,但它最多只能影响显示;支付 iframe 可以接收父页面消息,却不能凭一条“支付成功”消息改订单;WebSocket 能推送订单状态,但服务端只能把当前账号有权查看的订单发送给这条连接。这样一画,很多“前端问题”会自然落到真正缺失的控制点上。
“客户端不可信”不是说浏览器毫无安全能力。同源策略、沙箱、Cookie 属性和内容安全策略都很有价值。这里的“不可信”指的是:服务器不能把浏览器传来的状态当成最终事实,前端也不能把跨边界数据当成已经验证过的指令。

很多客户端安全讨论一上来就说“跨域”,但这个词经常把几件不同的事揉在一起。我们先把浏览器判断 origin 的规则说清楚:协议、主机和端口三项都相同,才是同源。
假设当前页面是 https://shop.lab.example:
同源策略主要限制跨源读取和脚本交互。它并不保证跨源请求完全不会发出:页面可以嵌入图片、提交表单,也可能发出某些满足条件的请求。于是评估 CORS 时,必须把三个问题分开:
CORS 解决的是第二个问题。服务器通过响应头告诉浏览器,哪些 origin 可以读取这份响应。它不是登录机制,也不是对象级授权,更不会替代防跨站请求措施。预检请求同样不是一道登录关卡;它只是浏览器在特定跨源请求前询问服务器允许的方法和请求头。真正的业务接口即使经过预检,仍要自己完成身份验证、权限检查和输入校验。
合理的 CORS 清单应该精确到协议、主机和端口,并只覆盖确实需要跨源读取的接口。把请求中的 Origin 不加判断地原样返回,看似解决了联调问题,实际等于把决定权交给了调用方。需要携带凭据的接口更不能用宽泛来源来兜底;公开、无敏感数据的资源和登录后的业务接口也不该共用一套配置。
评估时可以先做一张矩阵,而不是马上构造复杂请求:
浏览器显示 CORS 错误,只能说明脚本没有拿到可读响应,不能据此认定请求未到达服务器,更不能认定业务操作没有执行。报告里要分别记录“请求是否发送”“响应是否可读”“服务端是否授权”。

看前端代码时,最省力但也最容易误判的方法,是搜索某几个“危险函数”,找到以后就直接下结论。真正可靠的做法是追一条完整数据流:来源 → 转换 → 汇点。
来源是数据进入脚本的位置,常见的有地址栏参数、路径和片段、表单字段、接口响应、浏览器存储、跨窗口消息以及实时消息。转换包括类型检查、长度限制、规范化、映射、编码与净化。汇点则是数据产生效果的位置,例如写入页面、设置链接、选择资源地址、触发导航或交给能够解释代码的 API。
来源本身不是漏洞,汇点名字看起来危险也不等于一定可利用。关键在于数据到了哪个上下文。把昵称写入 textContent,浏览器会把它当作普通文本;把同一段数据交给 HTML 解析接口,浏览器会按标签和属性解释。把用户选择映射到固定路由通常容易控制,直接把地址栏里的任意字符串当作导航目标,边界就会变大。
下面的预览逻辑只允许一个短标题,并明确按文本显示:
const params = new URLSearchParams(window.location.search);
const rawTitle = params.get('title') ?? '未命名实验';
const normalizedTitle = rawTitle.trim().slice(0, 60);
const output = document.querySelector('[data-preview-title]');
output.textContent = normalizedTitle || '未命名实验'这段代码有两个值得保留的习惯。第一,长度和空白处理让界面行为可预测;第二,textContent 明确表达“这里只接收文本”。如果需求真的允许富文本,就不能靠简单替换字符解决。团队需要先定义允许哪些标签和属性,再交给维护良好的净化器处理,并为净化规则准备回归用例。HTML、属性、URL、CSS 和 JavaScript 是不同上下文,不能拿同一套编码规则到处套。
大型前端最怕的是每个组件都能随手解析 HTML、拼接 URL 或动态加载脚本。比“提醒大家小心”更有效的做法,是把这些能力收口:普通组件默认只能输出文本;富文本只能经过统一净化入口;导航必须通过允许的协议和路由映射;动态脚本加载必须经过资产清单。Trusted Types 可以进一步要求部分高风险 DOM 汇点只接收经过策略创建的值,CSP 则负责限制脚本从哪里加载和怎样执行。两者都是纵深防御,不能替代正确的数据处理。
审计时可以按功能分组追踪,而不是机械地逐文件翻阅:
每发现一条可疑数据流,再补齐输入控制者、执行上下文、受影响对象、服务器复核点和日志证据。这样写出来的结论会落在具体边界上,而不是一句泛泛的“过滤不严”。

localStorage 和 sessionStorage 用起来很像一个方便的小字典,于是项目很容易把越来越多的东西塞进去。问题不在 API 本身,而在于团队常常忘了两件事:同一 origin 中运行的 JavaScript 可以读取和修改它们,用户也能通过开发者工具改变其中的值。
localStorage 按 origin 划分,通常会跨浏览器会话保留;sessionStorage 同时按 origin 和顶层标签页会话划分,页面刷新后仍可能存在,标签页会话结束后才清除。IndexedDB 适合更大、结构更复杂的数据,Cache API 常用于离线资源,但这些存储同样不能自动获得“可信”或“保密”的属性。
Cookie 也不是天然安全,只是提供了不同的控制。HttpOnly 能阻止页面 JavaScript 直接读取会话 Cookie,Secure 要求通过安全连接发送,SameSite 可以限制部分跨站发送场景。服务器仍要管理过期、轮换、退出失效和权限变化。把令牌从 Web Storage 换到 Cookie,如果后端仍接受已退出会话或缺少跨站请求防护,问题并没有消失。
授权评估只需要知道“存了什么类别、谁写入、谁读取、保存多久、何时清理”,通常不需要复制真实内容。可以让团队准备一个虚构测试账号,把主题值从 blue 改为 green,把实验开关从布尔值改成普通字符串,观察页面是否安全降级。若修改本地的 plan 或 isAdmin 就让服务器开放了新能力,根因不是“localStorage 不安全”这么简单,而是后端把客户端状态当成了授权事实。
退出流程也要按数据类别设计。会话和敏感缓存应在退出或账号切换时失效,主题偏好未必需要清空。共享设备、隐私模式、存储配额和浏览器清理策略都会影响数据寿命,因此关键数据必须能够由服务器重新获取,页面也要处理存储不可用或数据损坏的情况。

不同 origin 的页面不能随意读取彼此 DOM,但 iframe、父页面和弹窗仍然需要通信,postMessage 就是为这类场景准备的。它提供了一条受控通道,却不会自动告诉业务“这条消息值得执行”。发送端和接收端都要验票。
发送端知道接收方时,应写出精确的目标 origin,不要用 * 图省事。目标窗口可能在通信过程中发生导航,精确目标可以避免消息落入新的页面。接收端先比较 event.origin,再确认 event.source 正是预期的 iframe 或弹窗,接着验证 event.data 的类型、版本、字段、长度和允许动作。即使 origin 正确,消息内容仍属于输入数据,不能直接当成 HTML、脚本或业务结果。
下面的协议只确认一个实验组件已经就绪,不涉及凭据和业务动作:
const widgetOrigin = 'https://widget.lab.example';
const widgetFrame = document.querySelector('[data-lab-widget]');
widgetFrame.contentWindow.postMessage(
{ type: 'lab.ready', version: 1, requestId: 'LAB-CLIENT-001' },
widgetOrigin
);
window.addEventListener('message', (event) => {
if (event.origin !== widgetOrigin) return
这里不能用“字符串里包含公司域名”代替精确比较,因为看起来相似的主机名并不一定属于同一边界。也不要把所有子域默认归为可信;一个无人维护的旧子域不应自动拥有给主站发送敏感指令的资格。
对于支付、登录和文件选择这类多步组件,仅检查 type 还不够。接收端要知道当前处于什么阶段,只接受当前阶段允许的消息,并使用一次性请求标识关联请求与响应。即便如此,消息也只表示“另一个窗口声称发生了某件事”。真正的支付结果、登录身份和文件权限仍要由服务器或受信后端通道确认。
一个低风险验证可以使用两个自有实验页面:可信页面发送结构正确的 lab.ack,另一个实验 origin 发送完全相同的无害消息。预期是前者更新“组件已确认”,后者被忽略并留下经过采样的拒绝记录。证据到这里已经够了,不需要让消息触发真实支付、跳转或数据读取。

WebSocket 先通过 HTTP 完成升级握手,之后在一条长连接上双向传递消息。聊天、协作编辑和实时看板因此不用为每次更新重新建立普通请求。也正因为连接长期存在,团队很容易犯一个错误:握手时登录过,后面的消息就全部放行。
生产环境应使用 wss:// 保护传输。服务器在握手时检查允许的 Origin,可以降低浏览器从非预期页面借用用户会话发起连接的风险,但 Origin 不是身份凭据,非浏览器客户端也能自行构造它。真正的认证仍要依靠受控的会话或短生命周期凭据,并避免把敏感令牌放在容易被代理、历史记录和访问日志保存的查询字符串里。
连接建立以后,每条有业务含义的消息都要重新经过协议和授权判断:
type 是否在当前协议版本的允许清单中;“能加入聊天室”和“能删除聊天室”显然不是同一权限;“能读文档 A”也不能推出“能读文档 B”。这些判断都应由服务端基于当前状态执行,不能因为客户端之前收到过某个频道名就跳过。
普通访问日志通常只看得到升级请求。应用层需要另外记录经过脱敏的连接标识、主体、origin、协议版本、消息类型、目标对象类别、授权结果、消息大小、限流结果和关闭代码。消息正文通常不应完整写入日志,凭据和敏感字段更要排除。退出、封禁或会话过期后,已有连接应及时关闭或重新认证,不能只阻止新连接。
最小验证可以使用一条无害消息:
{
"type": "lab.ping",
"requestId": "LAB-CLIENT-WS-001",
"sentAt": "2026-08-14T08:00:00Z"
}在授权靶场中每次只改变一个条件:未登录、非允许实验 origin、未知但无害的消息类型、退出后的旧连接。记录握手结果、消息级拒绝和连接关闭即可。不要做洪泛,不要订阅其他账号的频道,也不要用真实业务消息试探边界。

统计、客服、A/B 实验、地图和支付 SDK 经常从外部地址加载。很多人会想当然地认为:“文件在供应商域名上,浏览器应该会把它隔离起来。”通过普通 <script> 标签加载时并不是这样。脚本在嵌入页面的上下文中执行,通常能够接触页面 DOM、调用同源接口、读取脚本可访问的存储。对浏览器来说,它更像页面代码的一部分。
所以第三方脚本治理不能只问供应商是否知名,还要有一份能执行的清单:
对于内容固定的跨源脚本,子资源完整性校验可以让浏览器核对下载内容是否与预期摘要一致,但文件升级后也要同步更新摘要。内容安全策略可以限制脚本、连接和嵌入内容的来源;严格策略通常使用服务端每次响应生成的随机 nonce,或使用与固定内容匹配的哈希。部署时先用报告模式观察真实依赖,再清理内联事件、动态求值和不必要的来源,最后逐步执行,避免一上来就把正常功能全部拦掉。
能放进 iframe 的第三方功能,可以使用独立 origin 和最小 sandbox 权限隔离,再通过严格的 postMessage 协议交换少量数据。这里也要小心:随意同时放开脚本、同源、弹窗、表单和下载能力,沙箱就会被掏空。每一项权限都应能对应一个明确需求。
CSP、子资源完整性和 iframe 沙箱都属于纵深防御。它们不能替后端补上对象授权,也不能让已经获得页面执行权限的脚本自动变得可信。真正可靠的治理还包括资产清单、变更审批、数据最小化、告警和紧急停用开关。

前端校验当然应该做。用户少填一个字段时马上提示,比提交后再等服务器报错舒服得多;页面提前判断文件大小,也能减少无效上传。但只要这个判断关系到身份、权限、金额、库存、流程或对象归属,服务器就必须再算一次。
可以把服务端处理写成一条很朴素的顺序:先确认是谁,再解析输入,然后加载目标对象,检查这个人能不能对这个对象执行当前动作,依据服务端数据计算结果,最后把允许或拒绝都写入审计记录。顺序错了也会出问题,例如在授权之前就返回对象是否存在,可能泄露本不该知道的信息。
async function updateLabPreference(request, response) {
const actor = await requireSession(request);
const input = parsePreference(request.body);
const profile = await loadProfile(actor.userId);
if (!canEditProfile(actor, profile)) {
return response.status(403).json({ error: '操作未获授权' });
}
这个实验接口只修改主题,却仍然按主体、对象和动作授权。换成价格、审批或订单时,原则不变,只是业务规则更多。客户端可以把数据整理得更好,但决定权必须留在服务器。
客户端测试很容易越做越大:开发者工具里能看到更多数据,就顺手再看一点;实时连接能重放,就再换几个对象试试。这种扩张既没有必要,也会让证据失去边界。专业验证应该只改变一个变量,用无害结果证明控制是否存在。
开始前,先把授权文件翻译成浏览器能执行的范围:允许的 origin、端口、账号、时间窗口、消息类型、速率和禁止数据。产品名写在授权清单里,不代表登录中心、支付页、第三方客服和所有子域都自动在范围内。为本次测试新建浏览器配置,关闭个人账号同步,只保留必要工具,并使用一眼能识别的标记,例如 LAB-CLIENT-12。
先建立基线。记录浏览器版本、页面构建号、测试账号角色、当前 origin、正常操作的网络与控制台现象,确认实验数据不会与真实业务混淆。
再写一个可证伪的假设。例如“非允许 origin 的实验消息会被忽略”,或“退出后旧 WebSocket 连接会在规定时间内关闭”。假设要包含明确的预期结果。
只改变一个变量。使用固定文本、主题偏好或 lab.ping 这类无害输入,不修改金额、角色和他人对象,不增加频率,也不跨出授权 origin。
同时观察客户端与服务端证据。客户端记录 DOM、控制台和网络结果;服务端按关联标识查握手、拒绝原因、授权结果和会话失效记录。
客户端问题常跨越页面、网关和应用服务。日志至少要能关联请求或消息标识、测试主体、origin、对象类别、动作、协议版本、授权结果、拒绝原因和时间。对于 WebSocket,还要记录连接标识、消息类型、大小、关闭原因和限流结果;对于 CSP 与第三方资源,要对报告做采样、去重和敏感字段清理。
日志不是把消息正文全抄下来。完整令牌、Cookie、支付信息、个人数据和富文本内容不应进入普通审计日志。我们要的是能回答“谁在什么边界做了什么、系统怎样决定”的证据,不是再造一份敏感数据仓库。
“我在控制台里改成功了”不是一条完整发现。报告标题应该直接写出对象和缺失控制,例如“结算组件接收消息时未核对发送 origin”,或者“实时连接在账号退出后仍接受对象操作”。正文再沿着同一条线写:数据从哪里进入,经过哪些客户端处理,落到哪个汇点,服务器是否复核,日志能否确认。
修复建议也不要停在“过滤输入”四个字上。DOM 问题要写清安全输出上下文和统一净化入口;跨窗口消息要写精确 origin、窗口引用、schema 与状态机;WebSocket 要写握手认证、逐消息授权和连接失效;存储问题要写数据分类与服务器权威;第三方脚本要写加载范围、完整性、隔离和停用方案。
假设一个订单页面把主题放在 localStorage,通过 postMessage 通知支付 iframe 刷新展示,并使用 WebSocket 接收订单状态。请画出页面、支付组件和实时服务涉及的 origin,然后为三条通道分别写出:可控输入、允许影响、服务器复核点、日志字段和停止条件。
如果你能把一次客户端评估写成“来源—处理—汇点—服务端复核—日志”的完整链路,就已经越过了只看页面现象的阶段。浏览器负责提供安全机制和交互体验,服务器负责最终事实与授权,两边的日志负责让拒绝和异常可追查。
下一章会把视角从浏览器继续拉远。页面里出现的 API、身份服务、实时端点和第三方组件,都属于更大的应用架构。理解客户端的边界之后,我们就能继续检查网关、前后端分离系统和多服务调用链中,信任有没有在层与层之间被错误继承。
得到足够证据后立即停止,清理测试状态并复测正常流程。若出现真实数据、非预期外部请求或服务异常,也应立即停止并通知约定联系人。