ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Filter/Interceptor在HTTPS下的暗坑与实战排查

2026/9/20 4:20:10 拓冰建站 浏览量
Filter/Interceptor在HTTPS下的暗坑与实战排查 这个题目放在一起看有点“跨层”的味道Filter、Interceptor是Java Web里的经典拦截机制HTTPS则是网络传输层的东西。但真实项目里这三者恰恰是纠缠最深的组合。我之前接手过一个服务请求链路看起来没毛病——登录校验放Interceptor、访问日志放Filter、XSS过滤也放Filter结果一从HTTP切换到HTTPS怪事接二连三某些Filter里注入的RedisTemplate变成null有些重定向URL绕过了HTTPS跳到了http还有用户明明提交了内容后端日志里却根本找不到对应Filter的执行记录。这篇文章就是围绕这些真实场景展开的。我会从Filter和Interceptor的底层差异讲起再说清楚它们在HTTPS协议升级后会遇到哪些隐蔽的坑最后给出可落地的排查思路和职责划分建议。适合正在用Spring Boot做统一鉴权、日志、XSS过滤、接口防重等功能的开发也适合准备把服务从HTTP迁到HTTPS、但担心“切完会不会出问题”的团队参考。1. Filter 和 Interceptor名字像、职责像底层却属于两套体系很多初级开发会把Filter和Interceptor当成同一种东西的两种写法实际上它们运行在两套完全不同的体系里。理解这一点是理解后面所有“怪问题”的地基。1.1 Filter是Servlet容器里的一道“物理门禁”Filter由Servlet规范定义运行在Servlet容器Tomcat、Jetty、Undertow内部。从请求进入容器到离开容器FilterChain会按照注册顺序把请求挨个“过筛”。每一个Filter都有机会在请求到达Servlet之前做前置处理在响应返回客户端之前做后置处理。因为它处在最靠近协议的位置所以它能看到并操作最原始的请求信息请求URI、HTTP方法、Header、Body字节流、协议版本以及客户端地址前提是没有反代。它还可以通过包装Request和Response的方式偷偷修改参数、替换响应内容——XSS过滤器、响应压缩、请求日志、跨域处理靠的都是这个能力。可以这么理解Filter是火车站进站口的安检闸机。所有乘客请求必须先过安检Filter才能进入候车大厅Servlet容器内部业务处理。安检环节能搜出你包里带了什么读取Body也能拦下违规物品中断请求甚至能给你换一个更结实的行李箱包装Request。1.2 Interceptor是Spring MVC里的“业务关卡”Interceptor不同它是Spring MVC框架内的概念基于HandlerInterceptor接口实现。它运行在DispatcherServlet把请求分发到具体Controller方法之前之后本质上是业务调用链路上的关卡。常见套路是三个方法preHandle在Controller方法执行前调用返回false可以中断请求。postHandle在Controller方法执行后、视图渲染前调用适合往Model里补充公共数据。afterCompletion在整个请求完成后触发适合记录耗时、释放资源。它最大的特点是“活在Spring容器里”。因为FilterRegistrationBean、DispatcherServlet这些机制的配合Interceptor天然可以Autowired注入Service、Mapper、RedisTemplate等业务Bean。这也决定了它更适合做跟业务强相关的校验登录态判断、角色权限校验、操作日志、数据权限过滤。类比一下Filter是小区大门Interceptor是单元门禁。你进了小区大门Filter才能走到单元楼下DispatcherServlet分发然后按门铃Interceptor前处理进门Controller执行最后出门Interceptor后处理。门禁能查看业主名单注入Bean但小区大门保安管不了业主名单那么细。1.3 两条执行链路的差异决定了技术选型的方向一个HTTP请求在Spring Boot里的完整路径大致是这样的请求进入Tomcat - Filter1 - Filter2 - DispatcherServlet - Interceptor.preHandle - Controller - Interceptor.postHandle - 视图渲染 - Interceptor.afterCompletion - Filter2 - Filter1 - 返回客户端看这个顺序就能明白Filter在Interceptor的外层它的执行粒度更粗、更接近协议Interceptor在内层离Controller更近、更接近业务。两者不是替代关系而是天然的分层关系。下面这张表可以直观对比对比项FilterInterceptor定义来源Java Servlet规范Spring MVC框架运行环境Servlet容器内先于DispatcherServletDispatcherServlet内介于Handler与Controller之间访问请求流能获取原始ServletRequest/ServletResponse可包装只能拿到HandlerExecutionChain不直接操作底层流依赖注入需要特殊处理否则容易注入null可直接使用spring Bean中断请求通过不调用chain.doFilter中断通过preHandle返回false中断适用场景协议层处理、字符编码、XSS过滤、日志、压缩登录鉴权、RBAC权限、幂等控制、业务日志从这张表能推出一个关键结论想处理“请求长什么样”的问题用Filter想处理“这个请求该不该进业务”的问题用Interceptor。HTTPS切换后出问题的场景绝大多数发生在Filter这一层因为协议属性的失真、Header的丢失、Body流的消费这些都在Servlet层发生。但这个判断不能拍脑袋得靠真实场景里的现象反推。2. 关系着一个搜索热词的坑为什么Filter里注入的Redis总是null“filter 里注入的redis为null”这个词在搜索里出现得不少。我见过太多人在Filter里写字段注入然后被空指针折磨一整天。这里不卖关子直接说根因。2.1 现象全记录不是偶发而是机制问题我做过一个接口防重功能把逻辑写在OncePerRequestFilter里用来限制同一个用户对同一个接口的重复提交。代码结构很常见自定义一个Filter类内部用Autowired注入RedisTemplate然后在doFilterInternal里读Redis做校验。启动之后第一波请求直接空指针。诡异的是有时候重启后再点一次居然又能正常工作。原因是什么呢第一次请求恰好发生在Spring容器还没完全装配好时第二次请求时容器已经就绪注入才生效。这种“时好时坏”比“一直坏”更坑因为它会诱导人反复重启而不是直接查机制。2.2 根因解析谁创建了Filter谁才负责注入依赖Servlet容器Tomcat在启动阶段就需要根据web.xml或ServletContainerInitializer的配置创建Filter实例。而Spring的IoC容器是在这之后才完成扫描、创建Bean、填充依赖的。如果你用new XxxFilter()这种方式手动创建或者Filter不是由Spring管理的Bean那Spring根本不知道这个Filter的存在Autowired注解自然不会被处理字段永远为null。还有一个容易混淆的点Spring Boot里只要给Filter加了ComponentSpring容器确实会创建这个Bean但Servlet容器里的Filter实例不一定是同一个对象。为了让两者打通Spring Boot底层用了DelegatingFilterProxy——它作为Servlet容器里的一个“代理Filter”转发请求时再从Spring容器里找到真正的Filter Bean来执行。如果你绕过了这层代理机制或者启动顺序有偏差就会出现“注入看起来没生效”的现象。从生命周期来看问题本质是Filter的实例化时机和Spring容器的Bean装配时机存在先后如果你没有显式让Spring去管理Filter的生命周期依赖注入就是不可靠的。2.3 三种可靠做法建议按场景选第一种是构造器注入也是最推荐的方式Component public class RedisCheckFilter extends OncePerRequestFilter { private final RedisTemplateString, String redisTemplate; public RedisCheckFilter(RedisTemplateString, String redisTemplate) { this.redisTemplate redisTemplate; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 业务逻辑 filterChain.doFilter(request, response); } }因为Spring Boot的自动配置会扫描到Component由IoC容器实例化这个Filter构造器参数就能保证传入完整的RedisTemplate。这是最干净的做法。第二种是使用FilterRegistrationBean显式注册Filter并交给Spring管理Configuration public class FilterConfig { Bean public FilterRegistrationBeanRedisCheckFilter redisCheckFilterRegistration(RedisTemplateString, String redisTemplate) { FilterRegistrationBeanRedisCheckFilter registration new FilterRegistrationBean(); registration.setFilter(new RedisCheckFilter(redisTemplate)); registration.addUrlPatterns(/*); registration.setOrder(1); return registration; } }这种方式的好处是可以控制Filter顺序。当系统里有多个Filter时建议都用FilterRegistrationBean统一管理避免顺序错乱导致的诡异问题。第三种是手动从ApplicationContext中获取Bean适合需要动态获取、不建议构造器注入的场景Autowired private ApplicationContext applicationContext; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) { RedisTemplateString, String redisTemplate applicationContext.getBean(RedisTemplate.class); // ... }这种方式能用但我不太推荐作为常规手段因为它让依赖关系变得隐式排查问题时多一层“猜”的负担。2.4 遇到Filter里注入为null时按这个顺序排查如果接手一个项目发现Filter里注入为null不要急着把字段注入改成构造器注入。先按下面几步排查第一看这个Filter是用了new手动创建的还是注册成了Spring Bean。只要是new出来的注入必为null不用再查别的。第二如果标注了Bean或Component看是否被DelegatingFilterProxy正确代理。查容器里是否存在对应的Bean以及Servlet容器里的Filter实例是否指向同一个对象。第三看启动顺序。如果Filter在Spring容器初始化早期就被调用依赖注入可能尚未完成这时候改成构造器注入或延迟获取。第四检查是否自己手动new Filter并放进了FilterRegistrationBean同时又在构造器里传入了依赖。这种通常没问题但如果传入的是null问题就出在配置本身。这套排查链路我沿用很久绝大多数“Filter注入为null”都能在上面的框架内解决。3. 从HTTP切到HTTPS之后Filter/Interceptor真正要面对的五个暗坑解决了注入问题再看协议升级后的坑。HTTPS不像想象中那样“只是加了一层壳”它在实际部署中经常改变了Filter和Interceptor感知到的请求属性。3.1 原始Scheme“失真”你看到的是HTTP用户用的是HTTPS这是最典型、也最容易被忽略的问题。很多部署架构是用Nginx或负载均衡器把用户的HTTPS请求接住然后以HTTP形式转发给后端的Tomcat应用。在这个架构下后端Servlet拿到的request.getScheme()返回的是httprequest.isSecure()返回falserequest.getServerPort()返回的是8080而不是443。问题随之而来应用内部如果根据scheme来拼接回调地址就会生成http链接如果用isSecure()来决定是否给Cookie加Secure属性Secure属性就加不上如果做OAuth2之类的第三方授权回调回调地址与白名单不匹配直接授权失败。更难受的是从纯后端日志很难看出来。你以为应用收到的是HTTPS实际上它一直认为自己在处理HTTP。正确的处理方式是信任标准协议头。Nginx或负载均衡器把客户端原始协议传给后端常见的是X-Forwarded-Proto和X-Forwarded-Port。应用层读取这些头来还原真实协议String scheme request.getHeader(X-Forwarded-Proto); if (scheme null) { scheme request.getScheme(); }但在Spring Boot项目里我更推荐用框架自带的方案。Spring Boot 2.x及以上版本可以这样配置server: forward-headers-strategy: framework这个配置会让Spring MVC解析Forwarded或X-Forwarded-*请求头之后request.getScheme()、request.isSecure()就能返回客户端真正的协议。Spring Boot 3.x里配置项类似但底层实现稍有不同建议在迁移时实际验证一下。3.2 不只影响SchemeCookie的Secure属性和重定向都会跟着遭殃先看Cookie。HttpOnly和Secure是防范Cookie窃取的两道保险Secure属性要求浏览器只在HTTPS请求下携带这个Cookie。如果应用判断不出自己是安全的它就不会给Cookie加Secure属性。这时候你从HTTPS页面跳到HTTP页面Cookie仍然会带上遇到中间人就能被偷。解决方法就是在写Cookie时显式根据前端协议判断Cookie cookie new Cookie(SESSION, sessionId); cookie.setSecure(parseScheme(request).equals(https)); cookie.setHttpOnly(true); cookie.setPath(/); response.addCookie(cookie);再说重定向。登录拦截器是重灾区。我排查过一个问题用户访问https://example.com/order未登录Interceptor里的preHandle准备重定向到登录页结果响应头里的Location却变成了http://example.com/login。浏览器先弹了“不安全连接”警告然后用户就失去登录页面的Cookie上下文了。问题出在拦截器里用了request.getRequestURL().toString()再拼接而getRequestURL()用的是后端实际收到的协议即http。修复方式是和上面一样先统一解析客户端真实协议再拼URL或者直接配置forward-headers-strategy让getRequestURL()返回正确协议。3.3 TLS终止位置不同Filter看到的东西完全不同HTTPS最常见的部署形态不止一种Filter里看到的数据也不一样。整理成一张表部署形态后端Filter看到的Scheme后端Filter看到Body真实客户端IP来源Tomcat直接配置server.ssl.*自行终止TLShttps明文已解密getRemoteAddr()Nginx终止TLSHTTP转发给Tomcathttp明文X-Forwarded-ForCDN终止TLS回源给Nginx/Tomcat取决于回源协议明文X-Forwarded-For或CF-Connecting-IP这张表的意义在于调试HTTPS相关问题时第一件事不是看代码而是确认TLS终止点在哪里。如果终止点在Nginx后端的Filter根本看不到https所有基于“request.getScheme()https”的判断逻辑都是白写。如果终止点在TomcatFilter读到的是解密后的明文这时候做XSS过滤、日志记录是没有问题的但需要处理证书配置、性能开销。这个知识点对排查“HTTPS下Filter表现不一致”特别有用因为同样一套代码放在Nginx后面和放在Tomcat直连后面行为可能完全不同但它跟Filter代码本身没关系。3.4 HTTPS下“抓包看到明文”的前提让信任链通起来搜索热词里有“https明文捕获”这其实是调试HTTPS类问题时绕不开的操作。抓包工具能看到HTTPS明文是通过中间人代理的方式工具生成本机构证书客户端信任这个证书工具就能解密并展示明文流量。如果客户端不信任工具的根证书抓包结果全是密文无从分析。在Filter/Interceptor排障场景里这对应的操作是本地调试时让应用信任抓包工具的CA证书同时在Filter里临时打印Header、Scheme、Body关键片段。两者结合起来才能验证“应用收到的到底是什么”。如果连抓包都看不到明文那就先处理证书信任问题再谈Filter日志分析。3.5 从HTTP/2角度看FilterAPI没变但别忽略代理层的影响再提醒一句从HTTP/1.1切到HTTP/2后Filter的API层面几乎没有变化但底层传输结构变了二进制帧、多路复用、头部压缩。如果Filter里写了依赖请求顺序、连接状态的逻辑可能会受到干扰。但绝大多数情况下如果应用升级后出现Filter不执行、请求异常不要怀疑是HTTP/2带来的先检查代理层的路由和头部转发配置。很多“切HTTPS后出现问题”的案例最终定位到的是Nginx配置、证书链、回源策略而不是Java代码。4. 一个真实排障案例XSS过滤器在HTTPS流量下“离奇失效”这个案例值得单独写因为它把“Filter失效”“HTTPS”“多实例部署”这几个概念串在了一起。看下来你会明白很多“HTTPS切换导致的Filter问题”本质是流量路径变了而代码没跟上。4.1 问题表象线上HTTPS提交XSS Payload没被过滤内网HTTP却正常系统用的是Spring BootXSS过滤逻辑写在一个自定义Filter里用HttpServletRequestWrapper包装请求读取Body并替换危险字符。上线前在内网HTTP环境测试一切正常切到HTTPS对外提供服务后运营反馈有用户在评论区提交了带script的内容且这些内容原样存储了。我当时第一反应是Filter没执行还是执行了没生效不能靠猜直接加日志。4.2 完整排查链路从Filter日志到流量路径第一步在XSSFilter的doFilter入口处加了一行日志打印request.getRequestURI()。结果发现——线上“失效”的那条评论请求根本没有进入这个Filter。第二步查FilterRegistrationBeanURLPattern配的是/*理论上所有请求都该进。那问题就出在请求到达后端之前已经被更上层处理掉了。于是我翻了Nginx配置发现评论提交的路径在Nginx层做了特殊处理部分路径直接return压根没进后端应用。第三步把评论实际落库的服务拿出来看发现走的是另一台实例。手动验证了一下那台实例部署的JAR包版本里XSSFilter的代码根本没有包含进去。也就是说用户提交的内容实际上被路由到了一个没有XSSFilter的旧版本实例。而内网HTTP测试时流量刚好打到新版本实例所以看起来一切正常。第四步根因水落石出不是“HTTPS导致了Filter失效”而是切换HTTPS时引入了新的流量拓扑负载均衡和Nginx把部分请求导到了旧实例旧实例缺了这段过滤逻辑。HTTPS只是把这个隐患暴露在了用户面前。这个案例的排查链路可复制的地方在于先确认Filter到底有没有进来而不是直接打开过滤代码逐行读。因为Filter“失效”有太多种可能从流量路径到注册顺序任何一环出问题都表现为同一个现象。4.3 藏在暗处的第二个根因请求体是压缩流过滤器“看不见”明文流量路径排查干净之后还有一个容易翻车的点请求体压缩。代理层或客户端可能在请求前做了gzip压缩后端Filter读取InputStream时拿到的是一串无法识别的二进制你再用正则匹配“script”当然扑空。这不是HTTPS的问题而是“HTTP gzip 代理”组合下的常见陷阱。解决思路是在包装请求时按Content-Encoding做分支处理public class XssRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public XssRequestWrapper(HttpServletRequest request) throws IOException { super(request); this.body resolveBody(request); } private byte[] resolveBody(HttpServletRequest request) throws IOException { String encoding request.getHeader(Content-Encoding); String content readInputStream(request.getInputStream(), encoding); String safe cleanXss(content); return safe.getBytes(StandardCharsets.UTF_8); } Override public ServletInputStream getInputStream() { // 返回缓存的body字节流同时确保可用 } Override public BufferedReader getReader() { // 返回基于缓存body的reader } }这里还需要注意包装Request后Content-Length可能和原始不一致Controller如果不重新读取就会拿不到完整body。建议在包装时把原始的Content-Length清掉或者在读取后通过getHeader(Content-Length)重新计算。4.4 这个案例沉淀下来的四个排查方法第一给每个Filter增加入口日志记录URI和关键Header。排查“Filter是否执行”时这些日志就是第一手证据。第二把Nginx配置、网关路由、服务实例列表、Filter代码版本结合起来做对比。HTTPS切换往往伴随着入口拓扑调整实例版本不一致的情况很容易混进来。第三用curl或JMeter分别模拟HTTP内网、HTTPS域名的访问对比后端日志能快速定位流量是否打到了同一个应用实例上。第四在Filter里记录X-Forwarded-Proto、X-Forwarded-For、Content-Encoding、request.getScheme()这几个字段。一次记录排查时能少走很多弯路。5. 结合项目实践把Filter/Interceptor职责与HTTPS改造统一起来说了一堆坑最后落回设计层面。Filter和Interceptor的职责边界在HTTPS改造这件事上值得提前想清楚。5.1 “什么活派给谁”的分工表判断依据并不复杂谁手上有资源谁干活。Filter有协议层信息但没有业务BeanInterceptor有业务Bean但没有原始流。按下面这张表分配能避免大多数“我该放哪”的纠结功能场景推荐位置理由协议解析真实Scheme、客户端IP、协议头Filter需要读取原始Header且最好在业务链路上游统一处理XSS过滤、请求体缓存、压缩流解压Filter必须操作InputStream还需要包装Request登录鉴权、权限校验、接口幂等Interceptor需要大量查询Redis、DB依赖Spring Bean业务操作日志、审计日志Interceptor需要拿到HandlerMethod、方法参数并记录处理结果统一异常处理、响应包装Filter最外层兜底能捕获到整个链路抛出的异常跨域CORSFilter涉及协议头且需要在Servlet容器阶段处理OPTIONS预检这套分法不是硬性规定但基本符合“协议下沉、业务上浮”的原则。HTTPS改造里最怕的是把协议判断到处写切一次HTTPS就要翻遍所有Controller。统一放在最外层的Filter或配置里改动成本就小得多。5.2 HTTPS改造中建议预留的标准化扩展点我之前在做服务迁移时会在Filter里加一个“协议上下文解析器”把客户端真实协议统一解析后放进Request Attribute里业务层和Interceptor直接读取不自己调用getScheme()。大体思路是Component public class ProtocolContextFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String scheme request.getHeader(X-Forwarded-Proto); if (scheme null) { scheme request.getScheme(); } request.setAttribute(clientScheme, scheme); filterChain.doFilter(request, response); } }之后无论是Interceptor还是Controller读取request.getAttribute(clientScheme)就能拿到真实协议。这么做的好处是部署形态从Nginx反代变成CDN回源或者从直连变成加一层网关只需要改这一个Filter全链路跟着变。在Spring Boot里更彻底的方案是直接配置server: forward-headers-strategy: framework这样Spring内置的ForwardedHeaderFilter会自动处理X-Forwarded-*头getScheme()、isSecure()等都能返回客户端原始协议。两种方式可以结合使用看团队更接受哪种风格。5.3 本地调试与验证HTTP/HTTPS双端口跑通再上线我强烈建议在本地就把“应用在HTTPS背后的行为”复现出来而不是等线上出问题再排查。做法很简单本地同时跑HTTP和HTTPS两个端口。生成自签名证书keytool -genkeypair -alias localhost -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore localhost.p12 -validity 365然后配置application.yml支持双端口server: port: 8080 ssl: enabled: true key-store: classpath:localhost.p12 key-store-password: changeit key-store-type: PKCS12但Spring Boot默认一个应用只绑定一个端口如果想同时支持HTTP和HTTPS可以通过Spring Boot 2.x之后的方式额外配置一个TomcatConnector或者干脆用本地Nginx/Caddy做一层TLS终止代理后端仍跑在8080。这样本地就能完整复现“外部HTTPS - 代理终止TLS - 应用看到HTTP”的经典架构。我实际调试时最喜欢用后一种方式因为它在本地模拟了生产环境的链路Filter里的getScheme()、Header、Cookie行为才会和线上一致。否则本地直连HTTP很多问题根本暴露不出来。5.4 排查新问题前先问三个问题结合我自己的经验遇到Filter/Interceptor相关怪问题第一反应不要是读代码——代码大概率没问题。先问三个问题第一个请求到底有没有进这个Filter靠入口日志来判断别靠感觉。第二个TLS在哪里终止的是Nginx、Tomcat还是CDN这决定了应用眼里看到的是HTTP还是HTTPS。第三个应用看到的Scheme是什么真实客户端协议又是什么两者不一致时所有基于Scheme的判断都要重新审计。这三个问题问完基本上70%的“HTTPS切换后过滤器/拦截器行为异常”都能被快速定位。剩下的30%才是代码本身的逻辑问题。我折腾这类问题这么多次最大的体会是别把HTTPS想得太神秘它不改变Servlet API的语义但会改变请求到达应用的方式。而Filter和Interceptor恰好站在“请求如何到达应用”这条链路的关键节点上。只要把这条链路上的每一个环节搞得清清楚楚不管是Filter注入、XSS过滤、还是HTTPS协议头失真都能在几分钟内找到出口。