JBoss Web 安全问题实战总结(Session / CSP / Host Header)
JBoss Web 安全问题实战总结(Session / CSP / Host Header)
最近几天还在继续折腾 JBoss 的安全问题,有些问题能够从代码层解决,有些从代码层解决起来太麻烦了,不管怎么说,都先记录一下,省得以后如果 还这么倒霉地 碰到同样的问题,至少有资料可以参考一下
真的,搞了两个礼拜了,快放过我吧 🙏 这么老的东西继续折腾下去会秃的……
这次 copilot 真的是帮了倒忙了,我总觉得自己多 stack overflow 一下不至于花这么多的时间在正确的设置上……当然,我也没亏太多就是了……反正学到了就是自己的 wwwww
✅ url 中出现 session id
这种情况 URL 里面出现了 jsessionid,url 类似这样: https://localhost:8443/supermart/login.htm;**jsessionid=1A530637289A03B07199A44E8D531427
。因为这是明文出现在 URL,所以主要的问题是,**用户可以直接 cv session id,用在其他地方,比如说重载其他 url 里的 session id,获取当前用户不应该获得的信息
从 JBoss EAP 6.4 以上的版本,可以通过更新 web.xml 里,下面这个属性,让所有的 session id 通过 cookies 进行前后端传送:
<web-app xmlns="http://java.sun.com/xml/ns/javaee"version="3.0"><session-config><session-timeout>30</session-timeout><tracking-mode>COOKIE</tracking-mode></session-config>
</web-app>
✅ security headers
这里可以通过两个方法处理,第一个是下面会介绍的代码层处理,第二个可以通过 nginx 反向代理添加 headers
这里首先要确认 POM 里面存在下面这个依赖:
<dependency><groupId>javax.servlet</groupId><artifactId>javax.servlet-api</artifactId><version>3.1.0</version><scope>provided</scope></dependency>
如果是正式的项目,应该是会包含这个依赖的。注意这里的 scope 是 provided
web.xml 的配置中需要加 filter 和 filter-mapping,内容如下:
<web-app xmlns="http://java.sun.com/xml/ns/javaee"version="3.0"><filter><filter-name>SecurityHeaderFilter</filter-name><filter-class>com.jboss.mock.webapp.filters.SecurityHeaderFilter</filter-class></filter><filter-mapping><filter-name>SecurityHeaderFilter</filter-name><url-pattern>/*</url-pattern></filter-mapping>
</web-app>
这个配置写着写着,突然就想起当年写 Spring MVC 和 JSP 的日子了……
xml 文件主要是配置 java bean,下一步就是要实现 java bean 了。这里唯一需要注意的就是路径和类名需要是正确的,即 filter-class
和 SecurityHeaderFilter
。 filter-mapping
就是 mapping filter 和 URL 路径
java 代码如下:
package com.jboss.mock.webapp.filters;import javax.servlet.*;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;public class SecurityHeaderFilter implements Filter {@Overridepublic void init(FilterConfig filterConfig) throws ServletException {}@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)throws IOException, ServletException {HttpServletResponse response = (HttpServletResponse) servletResponse;response.setHeader("X-XSS-Protection", "1; mode=block");response.setHeader("Strict-Transport-Security", "max-age=0");response.setHeader("X-Content-Type-Options", "nosniff");response.setHeader("Content-Security-Policy", "default-src 'self'; " +"script-src 'self' 'unsafe-inline'; " +"style-src 'self' 'unsafe-inline'; " +"img-src 'self' data:; " +"connect-src 'self';");response.setHeader("X-Frame-Options", "DENY");response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");response.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=()");filterChain.doFilter(servletRequest, servletResponse);}@Overridepublic void destroy() {}
}
这里的 doFilter
可以操纵 Response 和 Request,我在这里就是加了 info sec 提出的,缺失的 security header。需要注意的是:
X-Frame-Options: DENY
会导致 iframe 加载失败,如果项目里还有对 iframe 的需求,可以改成SAMEORIGIN
Content-Security-Policy
里面的规则比较多,这里是需要细挖一下的。我这里放的都是比较宽松的规则,但是如果加了 CSP 后突然导致 API 无法加载了,那么就需要考虑一下是不是 CSP 的 rules 设定的太严格了
✅ iframe & session id & Angular 的生命周期
其实之前在 JBoss 项目修复笔记:绕开 iframe 安全问题,JSF 与 Angular 最小代价共存方案 里已经尝试抛弃 iframe 的写法,不过在遇到把 html 中的内容放回 xhtml 后遇到一些问题后,又 fallback 回了 iframe 的实现——不过在 debug 后发现,用回 iframe 之后问题反而更棘手了
回顾一下之前的问题:
- iframe 和主页面之间不共享 context,因此需要手动将 session id 放到 URL 里
- id 出现在 URL 里会有安全泄露问题
- id 不出现在 URL 里就无法将当前的 session id 传递到另一个页面中
所以第一步的问题出现在 session id 上,而且我至少想遵从原本的逻辑,在主页面中生成 session id,而非直接在 iframe 中的子页面中直接生成 session id——用户的状态控制主要在 template 中实现,直接在自页面中调用获取 session id 的方法,让用户可以绕开一些状态验证机制,直接进行 CRUD 操作
虽然我最后还是做了测试,因为一些依赖关系,子页面缺乏从 template 中传来的数据,JS 无法顺利进行操作,页面会直接崩掉,这个算是有点多虑了……不过我觉得这个想法还是没问题的,核心安全比较重要
同事是从远古时代的开发混过来的,当时还有用这样的写法进行过渡:
<input type="text" hidden="hidden" value="example_token_passed_to_iframe" />
这样可以通过 parent.document.querySelector()
的方法获取对应的数据
他这个想法很好,当时我非常绝望,以至于推翻了之前将 html 迁移到 xhtml 的想法,这么尝试了,然后发现坑……更多了……
不安全但好用的解法
这个就是我提到的,在 iframe 中使用 parent.document.querySelector()
的方法,本质上说在本来要获取 session id 的地方,通过这个方法去寻找,就能够获取当前的 session id
但是很可惜,我确实没写过这种操作——copilot 又很“智能”地提出了一个更加安全的方法,即 contentWindow.postMessage()
这个方法,然后我就开启了痛苦的 debug 周期
安全但痛苦的 postMessage
postMessage
用起来很痛苦的原因之一是——这是一个事件操作,AKA 异步操作,这个操作有涉及到另外一个问题 → Angular 的挂载&生命周期,这里的问题是:
- iframe 加载是一个异步的操作
- iframe 中的 js 文件需要 session id 才能继续
postMessage
是一个异步的操作
换句话说,iframe 在渲染时,无法保证已经从 postMessage
中获得了 token,这样势必会造成渲染失败
以流程图来说,大体是这样的:
在踩了很多坑之后才发现,解决方式也比较简单——在 event listener 中获取 token,随后加载所有的 JS 文件,确认所有的依赖注册完毕,最后挂载 Angular。debug 的周期比较痛苦,主要是因为 angular 的 controller 中有对其他 util 文件的 reference,写到后面就变成了 callback hells,自己的逻辑都丢了。后来没办法,重新做了个 refactor,用 async/await 锁住了 process,一个一个 js 文件加载,以保证生命周期没有问题
大概的实现是这样的:
- main.xhtml
这里的document.getElementById("angular-iframe").onload = function () {// any actual method to get the tokenconst token = "example_token_passed_to_iframe";// this.contentWindow.authToken = token;this.contentWindow.postMessage({ type: "SET_TOKEN", token }, "*"); };
*
还是比较重要的,是控制targetOrigin
的,我这里为了方便就写了个*
- iframe
这个其实真的卡了挺久的,主要的问题还是在绕死在了 1 → 2 → 3 的依赖关系,尝试花很多的时间去看愿代码,但是无解……function loadScript(src) {const script = document.createElement('script');return new Promise((res, rej) => {script.src = src;script.onload = res;script.onError = rej;document.head.appendChild(script);}) }async function initAngular() {try {await loadScript('src1.js');await loadScript('src2.js');await loadScript('src3.js');await loadScript('src4.js');angular.element(document).ready(function() {// 1st parameter is the div you want to load the angular appangular.bootstrap(document.body, ['ang-app-name']);})} catch(e) {console.error();} }window.addEventListener("message", function (event) {if (event.data?.type === "SET_TOKEN" && event.data.token) {// do the ops, store the token in window obj or anything elsewindow.authToken = event.data.token;initAngular();} )
最后决定不求甚解,能跑就行,进行重构后维持原本的顺序加载,自己对自己的要求就是代码简介能看明白就好
随后在对应的 JS 文件里面进行更新,将原本从 URL 中获得的 token,换成从 window 中获取
需要把 html 文档中本来出现的 ng-app
删掉,这样 Angular 不会自己管理 bootstrap 的事情。如果 ng-app
不删会有两个问题:
- Angular 抱怨初始化已经初始化的 app
- 仍旧无法解决 session 不存在的问题
总体上来说把所有的 html 文件搬到 xhtml 可以少掉很多这样的沟通,可以在一开始的 script 里面就将 token 放到 window 对象里面,这样也可以避免很多跨 context 的交流
Angular 加载的简述
我不是很想过多写很多的细节,因为一个比较现实的问题就是——这种题目面试大概率不会出现,毕竟 Angular1 已经淘汰了。不过理解一点细节,对以后 debug 类似的问题还是会有一定的帮助
……当然,最好不要这么倒霉……
在原本的 Angular,这种实现不会出错的原因还是在 session id 已经从 URL 里获取了,所以一开始的 JS 文件加载就不会出问题。Angular 本身的话,尽管 ng-app
出现的位置很靠前,Angular 还是有自己的机制,就是在 DOM ready 之后才会自动的 bootstrap
这就是为什么原本的代码可以跑,但是我修改了 session id 之后它就开始各种各样的报错……
难点就是在:
- 不断的
mvn clean install
特别浪费时间 - 本地没办法知道哪个 js 文件需要使用 session id——尽管可以把所有加载 JS 的功能都放到
initAngular
里去实现,这也是没有办法的办法…… - JS 文件的依赖性
- angular 必须在所有 JS 文件加载完毕后才 bootstrap——如果不是所有 JS 文件,那一定是有依赖关系的 JS 文件加载完毕
❌ server name
这个也是和我们现在用的 Jboss EAP 6.4 有关系,tomcat 里面还用 coyote 作为 connector,但是 coyote 本身已经是 EOL,而且 tomcat 还将这个 EOL 的 server 放到了 header 里面,这自然而然的就暴露了很多的问题
可惜的是我们的版本太老旧了,我们尝试找过几个方法,比如说:
-
修改 xml 中的 connector
<!-- find the connector, add added the server attributes --> <connector name="http" protocol="HTTP/1.1" scheme="http" socket-binding="http"enable-lookups="false" server=" " />
可惜这个版本现在不支持这种操作,
server
是个未知的参数 -
修改
subsystem
<subsystem xmlns="urn:jboss:domain:web:2.2" default-virtual-server="default-host" native="false"><!-- new valve configuration --><configuration><valve name="ServerHeaderValve"class-name="org.apache.catalina.valves.ResponseHeaderValve"><param name="header" value="Server"/><param name="value" value=""/></valve></configuration></subsystem>
也是同样的问题,Jboss EAP 6.4 不支持这种配置——准确来说,这个版本的 tomcat 还是一个半成品,没有做好完整接入,所以 catalina 并不存在,自然也就无法实现这个配置
-
修改 java code,加上
doFilter
的实现这个配置是完全没办法工作的,虽然我们手动重写了 response header,但是请求被送出去之前,tomcat server 自己还是把 server 给加上去了,这就导致 response header 中出现了两个 server……
以上配置,1 和 2 应该在 JBoss EAP 7+ 或 WildFly 8+ 开启支持,不过我本地还没有在 WildFly 做测试。JBoss 和 WildFly 实际上用的是两个不同的 standalone config,我忙着解决 bug,就只把 JBoss 拉出来做测试了
不过这不代表没有办法解决这个问题,最终我们还是得和 Nginx 团队联系,让他们清理一下 response header,将 server drop 掉
❌ Host Header Attack
这个是一个满容易被忽略的问题,主要因为我们现在做的都是内部项目,每次登陆不同的服务其实没有用 proxy 就根本无法访问,也确实有点忽略这个问题了
简单的解释下 host header attack,是在 http request 里面有一个 header 叫 host
,后端在没有做限制的情况下,是会 盲目 信任这个 host 的。InfoSec 提出的问题也是一样的,我们没有对这个 header 进行任何的验证,换句话说他们换成其他随机的网站,都能够从我们的 API 里面拉数据
这个方向要解决也是有两种方法,第一个是在代码层解决:
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)throws IOException, ServletException {HttpServletRequest httpRequest = (HttpServletRequest) request;String host = httpRequest.getHeader("Host");if (host == null || !ALLOWED_HOSTS.contains(host.toLowerCase())) {((HttpServletResponse) response).sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid Host header");return;}chain.doFilter(request, response);
}
但是这个最大的问题就在于,我们有不同的环境和 domain,如果全都写死的话升级会变得非常的困难。如果要搞配置文件的话,就是另一个更加痛苦的事情了……
所以目前的解决方案也是需要和 Nginx 组进行沟通,让他们在 reverse proxy 那里加上白名单,对 host
进行过滤控制