JSONP劫持的本质是利用了浏览器同源策略对script标签的豁免权。攻击者构造一个恶意页面,通过script标签跨域请求目标网站的JSONP接口,由于JSONP接口通常会将用户敏感数据包裹在回调函数中返回,而攻击者可以在自己的域下预先定义这个回调函数,从而窃取到返回的数据。要解决这个问题,最直接有效的方案不是完全禁用JSONP,而是对回调函数名进行严格的白名单过滤,同时配合Token校验和Referer检查等多重防护手段。

JSONP的工作机制与安全盲区

JSONP全称是JSON with Padding,它的诞生是为了绕过浏览器跨域限制。当浏览器发起跨域AJAX请求时,同源策略会拦截响应,但script标签加载外部脚本不受此限制。于是开发者将数据塞进一个函数调用里返回,前端提前定义好这个函数,script加载后数据就自动传入函数执行。比如服务端返回

handleData({"name":"张三","balance":5000})
,前端只要定义window.handleData函数就能拿到数据。问题在于,任何域下的页面都可以通过script标签引用这个接口,如果攻击者也在自己的页面定义了同名的handleData函数,用户的敏感数据就会被截获。这种攻击不需要XSS漏洞,不依赖Cookie的SameSite属性,纯粹利用JSONP的设计缺陷。

回调函数名可预测是最大软肋

大多数JSONP接口允许调用方通过URL参数自定义回调函数名,比如callback参数。服务端代码通常直接拼接用户传入的回调名到响应体中,类似

response.write(request.getParameter("callback") + "(" + jsonData + ")");
这种写法极其危险。攻击者只需在自己站点构造一个script标签指向这个接口,并指定一个自己控制的回调函数名,用户浏览器加载后数据就会落入攻击者手中。更隐蔽的情况是,有些开发者虽然固定了回调函数名,但攻击者仍可通过覆盖全局函数的方式来劫持,只要在恶意页面中提前定义同名函数即可。因此回调函数名要么完全由服务端生成不可预测的随机值,要么实施严格的白名单校验,绝不能让用户随意指定。

白名单过滤回调函数名的具体实现

服务端应当维护一个合法的回调函数名白名单,只允许符合特定命名规则的函数名通过。通常合法的JSONP回调函数名只包含字母、数字、下划线和点号,长度限制在50个字符以内,并且不能包含任何JavaScript关键字或危险字符。Java实现示例:

private static final Pattern CALLBACK_PATTERN = Pattern.compile("^[a-zA-Z_$][a-zA-Z0-9_$.]*$");
private static final Set<String> ALLOWED_CALLBACKS = Set.of("jsonpCallback", "handleResponse", "dataHandler");

public String sanitizeCallback(String callback) {
    if (callback == null || callback.length() > 50) {
        return "defaultCallback";
    }
    if (!ALLOWED_CALLBACKS.contains(callback)) {
        return "defaultCallback";
    }
    if (!CALLBACK_PATTERN.matcher(callback).matches()) {
        return "defaultCallback";
    }
    return callback;
}

这段代码做了三层过滤:长度限制、白名单匹配、正则校验。任何不满足条件的回调名都会被替换为安全的默认值。注意正则表达式中的$符号允许出现在开头,这是为了兼容jQuery等库生成的带命名空间的回调名。白名单方案虽然限制了灵活性,但对于安全性要求高的业务场景是必要的取舍。

Token化回调函数名彻底杜绝劫持

比白名单更彻底的方案是服务端生成一次性的回调函数名。每次页面加载时,服务端下发一个加密签名的Token作为回调函数名,这个Token与用户Session绑定,具有时效性且只能使用一次。前端代码需要先请求一个Token接口获取合法的回调名,再用这个回调名去请求JSONP接口。服务端验证Token的签名和时效性,不合法则拒绝返回数据。实现逻辑:

// 生成Token
public String generateCallbackToken(String sessionId) {
    String payload = sessionId + ":" + System.currentTimeMillis();
    String signature = hmacSha256(payload, secretKey);
    return Base64.getUrlEncoder().encodeToString((payload + ":" + signature).getBytes());
}

// 验证Token
public boolean verifyCallbackToken(String token, String sessionId) {
    String decoded = new String(Base64.getUrlDecoder().decode(token));
    String[] parts = decoded.split(":");
    if (parts.length != 3) return false;
    long timestamp = Long.parseLong(parts[1]);
    if (System.currentTimeMillis() - timestamp > 60000) return false; // 1分钟过期
    String expectedSig = hmacSha256(parts[0] + ":" + parts[1], secretKey);
    return parts[2].equals(expectedSig) && parts[0].equals(sessionId);
}

这种方案将回调函数名变成了一个不可猜测的凭证,即使攻击者构造了恶意页面,也无法获得有效的回调函数名,从根本上阻断了JSONP劫持。代价是多了一次网络往返,但对于涉及用户隐私或资产的接口来说完全值得。

Referer和Origin头校验作为辅助防线

在回调函数过滤之外,检查请求的Referer或Origin头是有效的补充手段。服务端应当验证这些头部是否来自合法的域名白名单。需要注意的是,Referer头在某些场景下可能被浏览器省略,比如从HTTPS页面跳转到HTTP页面、使用meta标签的referrer策略、或者用户直接地址栏输入。因此Referer校验不能作为唯一的安全措施,只能作为辅助。实现时要注意处理Referer为空的降级策略,建议对于空Referer的请求,如果是敏感数据接口则直接拒绝,如果是公开数据则可以放行。Origin头相对更可靠一些,但同样存在被篡改的可能,在浏览器环境中Origin由浏览器控制相对安全,但在非浏览器客户端请求中可以被任意伪造。

添加自定义请求头校验

JSONP请求本质是script标签加载,无法自定义HTTP请求头,这个特性恰好可以用来区分合法请求和恶意跨域请求。服务端可以要求JSONP接口必须携带一个自定义的HTTP头,比如X-Requested-With或者自定义的X-CSRF-Token。由于script标签无法设置请求头,攻击者构造的跨域JSONP请求自然无法通过这个校验。但这也意味着正常的JSONP调用同样无法携带自定义头,所以这个方案实际上是把JSONP接口变成了需要前置处理的接口——前端需要先通过其他方式获取数据,JSONP退化为纯粹的跨域数据传输通道。实际落地时,更常见的做法是直接废弃JSONP,改用CORS配合Access-Control-Allow-Origin白名单,这样既支持自定义请求头,又能精细控制跨域访问权限。

CSRF Token在JSONP接口中的应用

JSONP劫持本质上是一种CSRF攻击的变种,因此CSRF Token防护同样适用。服务端在用户会话中生成一个随机Token,要求JSONP请求必须在参数中携带这个Token。攻击者无法获取到用户会话中的Token值,构造的恶意script标签URL中缺少有效Token,服务端直接拒绝响应。实现时需要注意Token不要放在URL路径中以免被Referer泄露,建议放在查询参数中。同时Token应当与用户会话绑定,每次使用后刷新或者设置合理的过期时间。这个方案的前提是Token不能通过JSONP接口本身下发,否则就形成了循环依赖,通常Token会内嵌在页面HTML中或者通过单独的认证接口获取。

内容类型强制校验

很多JSONP劫持能够成功是因为服务端没有严格校验请求的Accept头或者响应的Content-Type。标准做法是JSONP接口应当检查请求是否来自script标签加载场景,如果是则返回application/javascript类型,如果不是则拒绝服务。反过来,服务端也可以强制要求JSONP请求必须携带特定的Accept头,而script标签加载时浏览器发送的Accept头与AJAX请求不同,通过这个差异可以过滤掉大部分恶意请求。不过这个方法的可靠性有限,因为攻击者可能通过其他方式触发请求,所以只能作为纵深防御的一环。

敏感数据接口禁用JSONP

最根本的解决方案是评估接口的数据敏感级别。涉及用户个人信息、账户余额、订单详情、身份凭证等敏感数据的接口,应当完全禁用JSONP,只允许通过CORS或者同源请求访问。CORS可以通过Access-Control-Allow-Origin精确控制允许哪些域名跨域访问,还可以配合Access-Control-Allow-Credentials决定是否携带Cookie。对于必须跨域提供数据的场景,建议采用postMessage进行安全的跨域通信,或者使用服务端代理转发请求,避免直接将敏感数据暴露在JSONP接口中。技术选型时应当将安全性置于便利性之上,JSONP的设计初衷是解决公开数据的跨域获取问题,而不是用来传输私有数据。

服务端输出编码与转义

即使回调函数名通过了白名单校验,服务端在拼接响应时也要注意输出安全。回调函数名中可能包含特殊字符,直接拼接可能导致响应被截断或注入。建议对回调函数名进行JavaScript字符串转义后再拼接,确保它被当作标识符处理而非可执行代码。同时响应体中的JSON数据也要保证格式正确,避免因为数据中包含特殊字符而破坏JavaScript语法结构。一个健壮的JSONP响应构造方法:

public String buildJsonpResponse(String callback, String jsonData) {
    String safeCallback = callback.replaceAll("[^a-zA-Z0-9_$.]", "");
    if (!safeCallback.equals(callback)) {
        throw new SecurityException("Invalid callback name");
    }
    return safeCallback + "(" + jsonData + ");";
}

这段代码先剔除回调名中的非法字符,如果剔除后与原值不一致说明存在危险字符,直接抛出异常拒绝服务,而不是默默替换后继续执行。这种fail-fast策略能更早暴露攻击行为,也避免了因自动修正导致的安全绕过。

前端侧的防御配合

安全防护不能只靠服务端,前端同样有责任。在使用JSONP的页面中,应当确保页面本身没有XSS漏洞,否则攻击者可以直接在页面中注入脚本读取数据,JSONP劫持就变得多余。同时前端应当避免将敏感数据缓存在全局变量中,JSONP回调函数处理完数据后应立即清除引用。对于现代前端框架,建议彻底放弃JSONP,改用fetch API配合CORS,或者使用WebSocket进行双向通信。如果历史遗留系统必须使用JSONP,前端应当在window对象上定义的回调函数中使用Object.defineProperty设置不可枚举和不可配置属性,增加攻击者覆盖的难度,但这只是增加攻击成本,不能完全阻止。

监控与告警机制

安全防护措施部署后,需要建立监控体系来发现异常访问模式。针对JSONP接口,应当记录每次请求的回调函数名、Referer、Origin、时间戳和用户标识。当检测到回调函数名不在白名单中、Referer异常、同一用户短时间内大量不同回调名请求等模式时,触发告警。这些异常日志往往是攻击者在探测接口的迹象。通过分析日志可以及时发现未授权的跨域调用,也能帮助识别哪些老旧接口还在使用不安全的JSONP实现,推动技术改造。监控数据还可以用于回溯安全事件,确定数据泄露的范围和时间点。

JSONP劫持的防护核心在于回调函数名的严格控制,白名单过滤解决已知合法调用的问题,Token化回调名解决不可预测性的问题,Referer校验和CSRF Token作为辅助层增加攻击难度,最终目标是让攻击者无法通过构造恶意页面来窃取JSONP接口返回的数据。多层防护叠加使用,即使某一层被绕过,其他层仍能起到保护作用。对于新项目,建议直接使用CORS替代JSONP,从根本上消除这类风险。