技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTETC

前端也需要了解的 JSONP 安全

所属馆藏
Other
文件格式
Markdown
原始路径
Other/07-前端也需要了解的 JSONP 安全
本文目录7 个章节
  1. 一、什么是 JSONP?
  2. 二、场景一:JSONP 导致敏感数据泄露
  3. 三、场景二:callback 参数注入和 XSS
  4. 四、场景三:第三方 JSONP 被攻陷
  5. 五、迁移到 CORS 和 Fetch
  6. 六、上线前检查清单
  7. 参考资料

前端也需要了解的 JSONP 安全

Category(分类): Other Status: 持续更新

JSONP 是早期浏览器中解决跨源读取问题的兼容方案。现代项目应优先使用 CORS、同源后端转发或其他标准通信方式;JSONP 本身会执行远程 JavaScript,不能当作安全的 JSON API。

一、什么是 JSONP?

JSONP(JSON with Padding)利用了一个历史事实:浏览器允许页面通过 <script src="..."> 加载跨源脚本。服务端不返回纯 JSON,而是把 JSON 放进调用函数中,例如:

<script>
  function handleUser(data) {
    console.log(data)
  }
</script>
<script src="https://api.example.com/user?callback=handleUser"></script>

服务端返回:

handleUser({"name":"Alice","role":"reader"})

这段响应会在当前页面的 JavaScript 上下文中执行,所以 JSONP 不是“绕过同源策略后安全地读取 JSON”,而是“把对方返回的代码交给当前页面执行”。JSONP 只适用于 GET,不能安全地承载修改状态的操作,也没有类似 CORS 的请求方法和响应权限控制。

原文中的百度百科链接可以作为历史背景阅读:JSONP

二、场景一:JSONP 导致敏感数据泄露

假设用户已经登录 www.example.com,站点提供了如下接口:

https://www.example.com/getUserInfo?callback=handle

攻击者可以在自己的页面中定义 handle,再使用 <script> 加载这个接口。如果浏览器向接口发送了满足条件的 Cookie,接口返回的数据就会作为参数传给攻击者页面中的函数。

这和 CSRF 的目的不同:

  • CSRF 主要是利用登录状态让目标站点执行一个动作;
  • JSONP/XSSI 主要是把目标站点返回的敏感数据泄露给其他页面。

可能泄露的数据包括用户信息、登录状态、令牌、订单和其他个人资料。攻击者不一定需要读取 Cookie,Cookie 只要被浏览器自动带上即可。现代浏览器的 SameSite=Lax 默认行为会减少跨站脚本子资源请求携带 Cookie 的情况,但显式的 SameSite=None; Secure、兼容性场景、其他认证方式和公共敏感数据仍然可能造成泄露,不能只依赖浏览器默认值。

1. 不要把 Referer 当成唯一防线

较早的做法是检查 Referer 是否来自白名单。它可以作为辅助信号,但不适合单独承担安全责任:

  • Referer 可能因为 Referrer-Policy、隐私设置、书签或代理而缺失或被裁剪;
  • 非浏览器客户端可以伪造请求头;
  • 只检查字符串包含关系会被相似域名绕过,例如 example.com.attacker.test 不是 example.com

对确实需要跨源调用的接口,应解析并精确匹配允许的协议、主机和端口;对敏感数据更重要的是认证、授权和不要使用 JSONP。Origin 在某些脚本 GET 场景中也可能不存在,因此也不能单独依赖它。

2. Token 不是 JSONP 的万能修复

给请求增加随机 Token 可以降低未经授权调用的机会,但如果 Token 放在 URL 中,它可能进入浏览器历史、代理日志、服务器日志、监控系统或 Referer。更重要的是,JSONP 的设计本身允许第三方页面发起 <script> 请求,服务端无法像检查自定义请求头那样确认“这是页面主动授权的读取”。

因此:

  • 敏感数据接口不要设计成 JSONP;
  • Token 只能作为具体认证和授权设计的一部分;
  • 不要把会改变状态的操作做成 JSONP GET;
  • 需要用户会话的操作仍要有 CSRF 防护。

三、场景二:callback 参数注入和 XSS

JSONP 的回调函数名通常来自查询参数。如果服务端把它原样拼到响应中:

?callback=恶意内容

返回内容就可能变成任意 JavaScript,从而在加载该接口的页面中执行代码。只删除 script 标签并不够,分号、括号、注释、事件属性和其他 JavaScript 语法同样可能改变代码含义。

1. 严格校验回调名

尽量固定回调名,或者只允许一个简单的 JavaScript 标识符。不要接受表达式、括号、方括号、分号、空白、注释和任意属性路径。示例:

const callbackPattern = /^[A-Za-z_$][0-9A-Za-z_$]*$/

function isValidCallback(value) {
  return typeof value === 'string'
    && value.length <= 64
    && callbackPattern.test(value)
}

校验失败应返回 400,不能静默替换成一个可能造成误解的回调名。回调名白名单、最大长度和输出编码应在服务端完成,不能只在前端校验。

2. 使用正确的 MIME 类型并禁止猜测

JSONP 的响应体是 JavaScript,因此正确的类型通常是:

Content-Type: application/javascript; charset=utf-8
X-Content-Type-Options: nosniff

纯 JSON 接口才应返回:

Content-Type: application/json; charset=utf-8

原文中“JSONP 必须设置为 application/json”的说法不准确:application/json 是纯 JSON 的类型,不能把它当成 JSONP 的安全修复。正确的 Content-Typenosniff 可以减少 MIME 混淆,但不能替代回调名校验、认证和最小化数据。

3. 一个仅用于公开数据的遗留实现示意

下面的代码只展示防止回调名直接注入的基本思路。真实项目应优先移除 JSONP,且该接口只能返回不敏感、只读的数据:

app.get('/legacy/list', (req, res) => {
  const callback = req.query.callback

  if (typeof callback !== 'string'
    || callback.length > 64
    || !/^[A-Za-z_$][0-9A-Za-z_$]*$/.test(callback)) {
    return res.status(400).send('invalid callback')
  }

  const data = { items: ['public-item'] }
  const json = JSON.stringify(data)
    .replace(/</g, '\\u003c')
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026')

  res
    .set('Content-Type', 'application/javascript; charset=utf-8')
    .set('X-Content-Type-Options', 'nosniff')
    .set('Cache-Control', 'no-store')
    .send(`${callback}(${json});`)
})

应使用成熟的序列化方案(例如内置的 JSON.stringify),不要手写 JSON。响应还要限制数据量、缓存策略和接口权限;如果回调名允许 foo.bar 等路径,则必须对每一段做同样严格的标识符校验,简单实现中不如只允许一个标识符。

原文提到的 /**/ 前缀和 2011 年 MHTML 漏洞(CVE-2011-0096)属于旧浏览器和历史 JSON 劫持问题。某些框架曾用前缀或换行兼容旧环境,但它们不能验证 callback,也不能阻止现代 JSONP 数据泄露,不能作为今天的主要防御措施。

四、场景三:第三方 JSONP 被攻陷

如果页面加载了第三方 JSONP:

<script src="https://third-party.example/data?callback=handle"></script>

第三方响应会以当前页面脚本的权限执行。对方服务器被入侵、依赖被污染、域名解析出错或接口内容被篡改时,攻击者可能读取页面数据、修改 DOM、发送请求或窃取输入。

请记住:所有第三方代码都不能被完全信任。

前端无法先把 JSONP 当作普通文本读取、检查完再决定是否执行;<script> 的加载和执行是同一个过程。下面这些措施各有边界:

  • CSP 可以限制允许加载脚本的来源,但允许的第三方源一旦被攻陷仍然有权限;
  • SRI 适合内容固定、哈希可预先确定的静态脚本,不适合每次返回内容不同的 JSONP;
  • iframe sandbox 可以把不可信内容放到隔离源中,再通过严格校验的 postMessage 传递数据,但实现复杂。普通固定源应使用精确的 targetOrigin;如果 sandbox 未设置 allow-same-origin,目标会是 opaque origin,发送时可能只能使用 *,此时必须校验已知的 contentWindow、消息来源和消息结构,且不要传递不必要的敏感数据;
  • 服务端代理 可以由自己的服务端请求第三方,再进行域名白名单、超时、大小、响应类型和数据结构校验,但不能做成任意 URL 的开放代理,还要注意 SSRF;
  • 最好的方案 是取消 JSONP,改用 CORS + fetch,或让自己的后端在同源下提供接口。

如果使用 postMessage,普通固定源不要把目标源写成 *,接收方也不能仅用 origin.includes('example.com') 判断信任关系。对于 opaque origin,event.origin 通常是字符串 null,不能把它当成唯一的信任依据,应结合 event.source === 已知 iframe.contentWindow、消息结构和业务能力校验。

五、迁移到 CORS 和 Fetch

现代浏览器中,推荐让服务端返回纯 JSON,并明确允许需要的源:

const response = await fetch('https://api.example.com/user', {
  credentials: 'include'
})
const data = await response.json()

服务端示例响应头:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Content-Type: application/json; charset=utf-8
X-Content-Type-Options: nosniff

带凭证的 CORS 不能使用 Access-Control-Allow-Origin: *,并且应只允许明确的来源。公开且不携带凭证的数据可以按需求使用 *。自定义请求头或非简单请求会触发 CORS 预检,但“预检通过”不等于业务授权;服务端仍要检查身份、权限和 CSRF。

CORS 控制的是浏览器脚本读取跨源响应的权限,不会替代服务端认证,也不会自动阻止所有跨站请求。详细行为可参考 MDN CORS

六、上线前检查清单

  • 敏感数据接口已移除 JSONP,改为 CORS 或同源后端接口;
  • JSONP 遗留接口只返回公开、只读数据,并限制 callback 格式和长度;
  • 不允许把 callback 直接拼成任意 JavaScript 表达式;
  • 返回正确的 Content-Type,并设置 X-Content-Type-Options: nosniff
  • 不把认证 Token、密码和个人敏感数据放在 JSONP URL 或响应中;
  • 第三方脚本经过来源、版本和变更审查,静态脚本在适用时使用 SRI;
  • CORS 使用精确来源白名单,带凭证时不使用 *,并设置 Vary: Origin
  • 对跨站读写分别考虑 XSSI、CSRF、XSS 和权限校验;
  • 只在授权环境使用 OWASP WSTG、ZAP 等工具验证。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS