全部笔记

CORS 不负责身份验证:浏览器限制与服务端权限要分开

解释跨域读取、预检与凭据规则,防止把允许来源列表当成 API 安全边界。

API 只允许 https://example.dev 跨域读取,攻击者仍能用命令行直接请求它。CORS 是浏览器实施的读取限制,不是服务端身份验证。把 Origin 检查当成唯一权限控制,会让非浏览器客户端轻易绕过。

本文讨论网页调用 API,特别是带 Cookie 的请求。公开数据接口与私人账户接口应该使用不同契约,不能共用一个“允许所有来源”的中间件。

先用三个客户端观察同一个操作

假设一个修改昵称的接口依赖 Cookie 会话。本站页面可以发送请求并读取结果;外站页面也可能在某些请求条件下发送到接口,但浏览器拒绝让它读取响应;命令行客户端根本不受浏览器 CORS 检查约束。若服务端只检查是否发送了允许来源头,攻击者可以在非浏览器客户端伪造头。因此接口必须验证会话和操作权限,不从跨域头推导用户身份。

请求已经到达与响应可被读取是不同事件。一个不需要预检的跨站表单可能改变状态,即使攻击页面无法看到结果。安全验收应检查服务端副作用,不以控制台显示跨域错误作为成功阻断写入证据。CSRF 正是关注浏览器携带身份执行未经用户意图授权的操作,与响应读取政策并不相同。

对于公开文章索引,任意网站读取可能完全合理。此时通配来源可以表达公开政策,但字段必须全部可公开,流量也需要成本预算。不能因为这个公共接口配置简单,就把账户接口也接到同一规则。按照数据敏感性分开契约,比建立一个对所有路由自动反射 Origin 的中间件更容易审查。

服务器决定谁可以读取响应

const allowedOrigins = new Set(['https://example.dev']);
const origin = request.headers.get('Origin');
const headers = new Headers(response.headers);
headers.delete('Access-Control-Allow-Origin');
const vary = (headers.get('Vary') || '')
  .split(',').map((field) => field.trim().toLowerCase());
if (!vary.includes('*') && !vary.includes('origin')) {
  headers.append('Vary', 'Origin');
}
if (origin && allowedOrigins.has(origin)) {
  headers.set('Access-Control-Allow-Origin', origin);
}

示例是响应头处理片段,response 是当前待返回响应,不包含认证。先克隆已有头并清理可能残留的来源许可,再在所有分支追加 Origin 变体;已有 Accept-Encoding 等字段与 Vary: * 保留,大小写不同的 Origin 不重复添加。默认、拒绝、错误与 304 响应都应经过同一处理,不能先缓存无 Origin 的默认响应再让合法来源复用它。实际返回时使用这些头重建对应响应,保留原状态和合法正文规则。需要凭据时还要设置明确的凭据策略,不能把 Access-Control-Allow-Origin: * 与带凭据访问当成通用组合。Origin 的精确匹配包含协议与端口,字符串包含检查会误允许相似域名。

Vary: Origin 提醒缓存存在来源变体,但平台实际缓存规则还应核对。若不同来源拿到不同 CORS 头却共用一个缓存对象,合法请求可能失败,或出现不该允许的读取路径。

origin 由协议、主机和端口共同定义,路径不属于 origin。允许一个来源后,它在该来源上的任何脚本都可能向接口请求,包括被注入的脚本。来源允许列表应从受控配置取得,不以 contains 或正则模糊匹配主机;相似域名和未登记端口应被拒绝。null 来源可能出现在沙盒或文件场景,不能作为所有未知来源的通用缺省允许值。

若业务支持客户自定义域名,允许列表需要绑定到已验证租户域名,并保持撤销。不要把客户端请求中的来源直接存入白名单。来源验证完成以后,仍要检查请求属于哪个用户、哪个租户以及是否有操作资源的权限。跨域许可只说浏览器可以让脚本读取,不说该来源中的每个用户都能读取全部账户。

响应包含动态来源头时,缓存变体也要正确。若公共响应对象被第一个来源的头污染,第二个合法来源可能无法读取。添加 Vary 时要保留已有字段,不能覆盖 Accept-Encoding 或其他变体。私人响应更需要正确私有缓存策略,不因为 CORS 头看起来严格就放入共享对象。

预检不是每个请求都会发生

浏览器对某些方法、头与内容类型发 OPTIONS 预检。符合简单请求条件的请求可能直接发送,服务端即使没有允许读取,副作用也可能已经发生。因此“不配置 CORS 就不会收到跨站请求”是错误理解。

处理预检时,应限制允许的方法与请求头,并返回适当缓存时间。不要把客户端请求的所有头名原样反射成允许列表。预检缓存影响排障,修改配置后应测试新浏览器上下文,不要把旧缓存结果误当成线上仍未更新。

OPTIONS 请求用于询问跨域方法和头是否允许,不能在预检分支执行真正业务写入。通常预检不携带与实际请求相同的身份凭据,不应简单套用会话认证后拒绝全部预检;可以依据来源与目标路由政策返回允许信息,真正请求再认证授权。预检成功也不是未来请求永久授权,服务器仍处理每次操作。

允许方法应来自接口定义,允许请求头来自确实需要的字段,不反射客户端请求的任意值。返回合理预检缓存期限可以减少重复请求,但会延迟客户端观察到配置变化;浏览器也可能施加自己的上限。修改策略后在新的浏览器上下文测试,并同时检查实际请求响应的 CORS 头。仅预检返回成功而业务响应缺头,仍会导致读取失败。

自定义响应头需要按浏览器规则暴露给脚本,网络面板能看到不代表 fetch 代码能读取。下载名、请求 ID 和分页信息若通过头返回,应明确暴露字段;不暴露内部诊断、认证信息或不必要的服务器细节。前端数据协议包含正文和可读头,不能只验证其中一部分。

身份与 CSRF 有独立责任

认证决定请求属于谁,授权决定能操作什么。Cookie 会话还需要考虑 CSRF:浏览器可能自动带上会话,攻击页面诱导请求。根据应用架构使用 SameSite、CSRF token 与来源验证等措施,不能让 CORS 承担它没有承诺的写入保护。

Bearer token 也不是万能替代。若令牌存储在可被注入脚本读取的位置,XSS 风险会上升。选认证方案应基于威胁模型和客户端条件,而不是因为配置 CORS 麻烦就把私密接口改成匿名。

浏览器调用不同来源接口时,需要按 fetch 的凭据策略发送 Cookie,同时服务端返回具体允许来源与凭据许可。即使这些都正确,Cookie 的 SameSite 属性和浏览器第三方限制也可能阻止发送。CORS 允许凭据不强迫浏览器放弃 Cookie 隐私政策,排障时应分别检查发送条件、接口认证和响应读取。

不应为方便跨站调用直接把所有 Cookie 改成 None。确认是否可以同源代理或采用明确认证架构,先减少跨站会话依赖。Bearer 令牌由客户端显式发送,具有不同 CSRF 特征,但存储与 XSS 风险仍需处理。认证方式应基于威胁模型,不以减少一条预检为唯一目标。

需要防 CSRF 的写接口采用适合架构的令牌、SameSite 与受控来源验证。来源验证可以参与防护,因为浏览器提供相关上下文,但它与服务端身份授权仍然分开。对于后台任务,使用独立服务凭据,不为了兼容无 Origin 而允许匿名写入。多个客户端类型应各有可解释认证路径。

不用宽泛 CORS 掩盖服务器故障

上游异常可能让代理返回不带 CORS 头的五百响应,浏览器只报告跨域读取失败,开发者随后把来源规则放宽,却没有修复真正故障。应在所有需要被允许客户端读取的错误响应上应用同样来源政策,同时保持真实状态与安全正文。这样前端能够显示受控故障,而不是只能得到泛化网络异常。

调试先看请求是否发出、预检是否成功、实际响应是什么、浏览器是否允许读取。服务端日志能证明到达与认证结果,浏览器网络面板能证明协议行为。不要为读取日志而把内部错误全文暴露到外部客户端。关联标识让两个视角对齐,足以找到根因。

跨域代理还容易绕过原有边界。开发服务器提供代理方便本地调试,但生产前端直接请求上游时会重新遇到 CORS。必须明确生产调用路径是同源 Worker 还是跨域 API,不把开发代理成功当成生产架构已确定。若采用同源代理,代理自己仍需要身份、授权和上游范围限制。

来源政策可以版本化,并在部署中验证已登记入口。域名迁移时,新来源加入与旧来源撤销需要计划;永久保留所有历史预览地址会不断扩大允许范围。短期预览域名使用有限受控规则或独立测试接口,不把任意用户创建的预览主机都授权到生产账户 API。

对于不可信嵌入页面,null 来源不是一个具体可信网站,多个不同沙盒场景可能共享这种表达。若产品确实需要它,应限制数据与操作,并独立认证,不把它作为宽泛兼容开关。每个额外允许来源都应说明谁控制、为何需要、何时撤销。清楚的读取政策加真实权限判断,才比一串为了消除控制台报错的头更可靠。

从一次带会话修改推演攻击与合法请求

用户已经登录,外站页面诱导浏览器向昵称接口发送请求。如果请求满足不需要预检的条件,服务器可能直接收到带身份上下文的请求,具体 Cookie 是否发送取决于站点关系、属性和浏览器策略。响应读取被 CORS 拒绝,仍不能证明写入未发生。安全测试需要查询真实昵称或受控计数器,确认服务端是否接受了操作。

合法本站页面发送同一操作时,服务端先验证会话、权限与适用的防 CSRF 条件,再执行写入,然后按来源政策返回可读响应。防 CSRF 方案依据会话架构选择,不能随意将几个示例规则拼接当作通用保证。令牌要绑定正确会话与验证流程,来源检查要精确,SameSite 要与真实跨站需求匹配。每项保护都有适用边界,不能由 CORS 读取失败替代。

非浏览器客户端直接发送伪造 Origin,并不能凭这个头获得身份。它仍然需要有效认证与权限,后台任务使用独立服务凭据。若接口仅检查 Origin,测试这个客户端就会揭示漏洞。不要为了兼容无 Origin 客户端关闭全部身份判断,也不要把 Origin 缺失统一视为认证失败。客户端类型与身份方式分开定义,规则才不会在兼容修复中摇摆。

用请求账本分离预检与真实操作

预检询问路由允许哪些方法和头,不执行昵称修改。测试上游为 OPTIONS 与 POST 分别记录合成计数,断言预检多次出现也不会增加业务操作数。预检成功之后实际请求仍要验证身份,客户端不能把一次允许结果当作长期授权凭证。浏览器的预检缓存只减少政策询问,不让服务器跳过业务授权。

一个失败预检可能让实际请求根本没有发送,另一个简单请求可能绕过预检直接到达。前端同样显示跨域错误,但服务器账本不同。记录请求到达、身份结果、业务执行与响应读取四项,就能准确解释。日志中只存类别与关联标识,不记录 Cookie、令牌或完整私人正文。

预检错误还可能来自路由不存在或服务故障。先检查状态和实际响应,再判断允许头是否匹配。服务器故障不应该通过反射更多来源“修复”,被允许客户的错误响应也应有一致政策以便前端读取安全类别。读取一条受控错误不等于允许客户获取内部堆栈,错误表示仍需最小化。

客户自定义域名需要可撤销的来源映射

多租户产品允许客户域名调用 API 时,域名先经过所有权验证,再登记到对应租户。请求 Origin 匹配来源,认证用户匹配租户,资源权限匹配目标,三项分别确认。允许域名不让该域所有用户读取其他租户资料,也不意味着域名永远可信。撤销绑定或账户停用后,来源映射需要同步退出。

精确来源包括协议与端口。子域通配可能覆盖客户控制的站点或遗留服务,不应为了方便把整个域树允许。预览环境来源也要与生产分开,任意外部贡献者创建的预览页面不应因此获得生产账户读取政策。选择有限受控预览或测试 API,减少域名不断增长的长期权限。

来源验证可以允许读政策,身份授权才限制数据。测试两个租户各自合法域名与用户,再交叉组合,确认来源合法但租户不匹配的请求仍被拒绝。服务端接口对于无 Origin 的受权后台调用依然可用,说明安全来自授权而非只有浏览器头。这个交叉矩阵比一个来源字符串的单元测试更接近真实边界。

缓存头必须与公开和私有表示一致

公共文章索引可能允许所有网站读取,私人账户结果则不能放进同一个共享缓存对象。动态反映 Origin 的头需要正确的变体处理,已有 Vary 字段不能被覆盖。平台是否理解对应变体要在真实部署验证,尤其是手动缓存或自定义键。协议声明与平台实现不是同一层保证。

先请求来源甲,再来源乙,并反向执行,检查返回头是否污染。再改变认证用户,确认私人正文没有被另一个用户复用。CORS 头合法而正文串用仍是严重漏洞,浏览器只判断来源许可,不验证服务端是否返回正确账户。缓存资格、身份隔离与读取政策需要共同成立。

请求配置与服务端允许凭据都正确,浏览器仍可能阻止跨站 Cookie。检查实际发送的 Cookie、SameSite、Secure 和当前浏览器限制,不用任意反射 Origin 解决不存在的会话。可以评估同源代理减少跨站依赖,但代理需要完整认证、授权和上游约束,不是一个自动安全的兼容层。

Bearer 方案改变凭据传递方式,也增加令牌存储与 XSS 防护责任,不能因为预检麻烦就转换认证。架构决定应记录可信执行环境与攻击模型,再选择方案。最终验收同时证明合法任务成功和未授权副作用被拒绝,控制台没有跨域报错只是兼容结果之一。

下载跳转把第二个服务器带进读取合同

报表接口认证成功后返回一个对象存储下载地址,前端用 fetch 跟随跳转,最后仍遇到读取错误。排障要看完整请求链:入口 API 的 CORS 头并不能替最终存储响应批准读取。最终目标需要符合对应浏览器读取规则,原入口的凭据也不能被想当然地当作所有目标的访问资格。

先明确交付方式是脚本读取内容,还是让用户导航到受控下载地址;两种方式的浏览器协议不同,不能把“新标签能下载”当作 fetch 已兼容。签名地址如被采用,具有自己的有效期、对象范围和泄露风险,不在公开日志打印完整查询参数,也不为解决读取错误将整个存储空间开放。

隔离验收用一个无敏感内容的对象,记录入口响应、跳转目标类别与最终状态,并分别测试有效和过期授权。结果报告区分 API 拒绝、存储拒绝和浏览器不可读。这里没有执行真实供应商下载实验,项目仍需核对其签名与响应头能力;更换交付方式不能绕过用户的报表归属检查。

测试“被读到”与“被执行”两件事

准备本站页面、外站测试页面和命令行客户端。观察请求是否到达服务端、操作是否发生、浏览器是否允许脚本读取。三种结果不同,日志需要能区分。

测试无 Origin、null Origin、相似域名、不同端口和未授权用户。无 Origin 的命令行请求仍必须经过认证;不要为了浏览器校验而把后台任务全部拒绝,也不要因为兼容后台任务而允许匿名访问。

公开 API 如果确实允许任何网站读取,使用通配来源可以是合理选择,但仍要限制滥用、成本与敏感字段。CORS 配置应描述跨域读取政策;API 安全应由身份、权限、输入校验与限流共同保证。

一个常见“修复”是把 fetch 改为 mode: 'no-cors'。它不是绕过服务器允许规则取得正文的开关;跨来源请求得到的不透明响应不提供脚本正常读取状态、头和正文的能力,方法与请求头也受限制。若任务是解析账户 JSON,这种模式没有完成原任务。测试需检查脚本是否真正取得预期字段,而不是只看 Promise 没有进入异常分支。

登录响应的 Set-Cookie 又属于另一边界:浏览器可以按规则处理 Cookie,却不让前端脚本通过响应头接口读取该头。加入暴露头清单不能把它变成可读会话字段。会话是否建立应由受控后续身份请求与浏览器存储行为验证,不需要把 Cookie 值返回给 JavaScript。响应读取、浏览器存储与后续授权依然是三个独立结果。

参考:MDN CORS、Request.mode、Set-Cookie、OWASP CSRF 防御。