前端也需要了解的 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-Type 和 nosniff 可以减少 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 等工具验证。