☰
JavaEE核心组件详解:Servlet、Filter、Listener与安全开发实战
2026/10/7 3:06:30 网站建设 项目流程

现在很多程序员学Java Web开发,第一步就是Spring Boot,一个注解加一个依赖就跑起来,根本不知道背后发生了什么。但一旦开始做代码审计、接手老项目、或者排查线上一个莫名其妙的乱码和CSRF攻击时,你才会发现,Servlet、Filter、Listener、JSP这些JavaEE里看似“过时”的东西,其实一直活在生产环境的底层。我最近正好跟着一套安全向的Java开发课程把day32~40这部分集中啃了一遍,从VS Code配置JavaEE语言环境开始,到完成一个带登录鉴权和图书管理功能的Web应用。这一篇就把这整个过程中的环境搭建、核心组件协作、分层代码组织,以及开发时需要特别留神的几个安全问题,一次性梳理清楚。

如果你是刚接触JavaEE、又被各种视频课程里的术语劝退的话,这篇文章可以当作一条相对平滑的路径:我先解释清楚这块内容的定位,再带你一步步配好VS Code环境,然后通过一个可运行的登录+图书管理项目,把Servlet、Filter、Listener、MVC分层和安全编码串起来。看完之后你应该能独立把一个war包部署到Tomcat上,也会明白为什么很多安全报告里反复出现那些Java Web漏洞。

1. 一上来学JavaEE,心态先纠正过来

1.1 JavaEE从来不是一门语言,而是一组“契约”

很多人会把JavaEE当成一个像Python、Go那样的具体技能,其实它是一整套企业级开发规范的集合。Servlet、JSP、EJB、JMS、JDBC、JTA,这些都是JavaEE体系里的成员,只不过后来Spring把其中大部分底层的活都封装完了,导致很多新人在写Spring Boot时不觉得自己在用JavaEE。

但底层逻辑没有变。Spring MVC的核心还是DispatcherServlet,它本身就是一个Servlet;Spring Security的过滤器链,本质上就是在一层又一层Filter上做文章;Spring Boot内嵌的Tomcat、Jetty,也依然是一个Servlet容器。也就是说,不管外层包装得多花哨,JavaWeb运行时还是离不开这些JavaEE规范。

我用一个类比来理解这套东西:Servlet像快递柜里的格子,规定好每个包裹怎么存取;Filter则是快递柜门口的检查员,所有包裹进出都要过一遍;Listener是柜子里的感应器,开门、放东西、关门都会触发通知。三者的组合,能覆盖一个Web请求从进来到最终离开的完整生命周期。

1.2 为什么这套课程把day32到day40单独拎出来讲

day32到day40是这套课程里非常特殊的一段。前面的内容大多在铺垫网络基础、HTTP协议、数据库操作,到这里才开始真正把“请求从浏览器发出,Java代码怎么接住并处理”这件事串起来。课程虽然顶着“安全”的名头,但这个阶段的重点并不是攻击技巧,而是让开发者知道自己写的JavaEE代码会引入哪些漏洞。

比如一条SQL语句,如果用的是字符串拼接,那注入风险就摆在明面上;如果没有统一的编码Filter,中文乱码和潜在的编码绕过就会烦死人;如果文件上传接口没有校验扩展名和路径,那几乎等于给服务器开了一个后门。对于想做代码审计的人来说,这一段的产出非常直接:你得能看懂Servlet里一条SQL是怎么被拼出来的,才理解安全报告里的“注入点在doPost方法第47行”到底是什么意思。

我自己的目标也在这个阶段做了调整。之前我一直觉得能把Spring Boot项目跑起来就算会Java Web,但遇到老项目的时候,打开全是JSP和Servlet的代码,连从哪下手都不知道。补完这一块之后,再回头看那些框架源码,明显顺畅了很多。

1.3 我在这一阶段前已经具备的基础

如果你是零基础直接切入,我建议先准备三个前置点:能用Java写普通的类和方法,理解HTTP的GET和POST区别,会看最基本的数据库表结构。不一定非要多熟练,但至少要能读代码。这套内容本身讲JavaEE,不太会回头教Java语法。

我的实际节奏是每天一到两节课,每节课后都不急着往下看,而是先把示例代码手敲一遍,改一改参数,自己造几个错误来观察现象。刚开始很容易把doGet和doPost写反,或者在web.xml里漏掉servlet-mapping导致404。这些问题看起来很基础,但恰恰是理解请求路径映射的最好机会。

如果你已经有一点Spring Boot基础,那反而要刻意“忘掉”自动配置。用纯Servlet写接口的时候,你会发现自己要多写很多行代码,这其实是一件好事:控制器的路由、参数的读取、响应的返回,每一步都变得可见了。

2. VS Code配置JavaEE语言环境:从下载到第一个Servlet打印Hello World

2.1 为什么最后选了VS Code而不是IDEA

现在社区里主流的JavaEE开发工具还是IDEA,这个没得争。但我个人选VS Code当主力,原因很简单:轻,而且平时还得写脚本、看前端代码,不想为了一个项目常年开着IDE。

JavaEE开发在VS Code里完全可行,尤其是配合Maven之后,编译、打包、依赖管理都跟在IDEA里差不多。关键是把VS Code当成一个增强的编辑器来用,别去追求图形化点按钮创建Servlet,而是用命令行生成项目结构、用配置文件声明依赖。这样反而能让人更清楚工程的骨架是什么。

如果你后面打算长期做Java后端,IDEA还是有它的优势,但如果你是学习阶段、或者需要快速验证一个Web应用,VS Code这套方案足够。

2.2 一步一步装齐JDK、Maven和Tomcat

首先是JDK。我自己选了JDK 11,因为兼容性比较好,课程里涉及的Servlet 4.0、Tomcat 9都能稳定支持。如果你要跑的Tomcat版本在8.5以下,建议还是用JDK 8。安装完之后,在命令行执行java -version,看到类似openjdk version "11.0.x"的输出就行。

然后装Maven。Maven的作用是管理依赖和构建,这里提供一个可靠的下载源,解压后配置环境变量到bin目录。如果下载依赖太慢,记得改一下settings.xml里的镜像仓库。Windows下常见的坑是每次启动终端都要重新set MAVEN_HOME,这个别嫌麻烦,直接在系统环境变量里加好,一劳永逸。

接着是Tomcat。我用的是Tomcat 9,它对应Servlet 4.0规范,符合课程这一阶段的设定。解压到你习惯的目录后,进入bin目录执行startup.bat启动一次试试,浏览器打开http://localhost:8080能看到Tomcat首页就算成功。这里建议把CATALINA_HOME环境变量也配上,后面VS Code里面识别会方便很多。

2.3 装哪些VS Code扩展,settings.json怎么设

VS Code里面做JavaEE开发,最少要装两套东西。一套是Java语言支持,直接搜Extension Pack for Java,红帽出的,包含语言服务、调试器、Maven支持。另一套是Tomcat运行插件,搜索Tomcat for Java,装好之后就能在侧边栏管理本地Tomcat,也可以直接把war包部署上去。

装完扩展后,我推荐手动配置一下settings.json,避免后续环境变量识别出问题。下面这份是我一直在用的配置:

{ "java.configuration.updateBuildConfiguration": "automatic", "java.configuration.runtimes": [ { "name": "JavaSE-11", "path": "C:\\Program Files\\Java\\jdk-11", "default": true } ], "maven.terminal.customEnv": [ { "environmentVariable": "JAVA_HOME", "value": "C:\\Program Files\\Java\\jdk-11" } ] }

如果你的JDK装在其他路径,把path和value换成自己的就行。有个小细节:"java.configuration.updateBuildConfiguration": "automatic"一定要开,否则改了pom.xml之后,扩展不会自动更新依赖,代码里会一直提示找不到javax.servlet相关类。

2.4 用Maven骨架创建Web工程并配置Tomcat运行

打开VS Code的终端,执行下面的命令:

mvn archetype:generate -DgroupId=com.demo -DartifactId=bookmanager -DarchetypeArtifactId=maven-archetype-webapp -DinteractiveMode=false

这个命令会生成一个最简单的Maven Web工程,目录结构长这样:

bookmanager |-- pom.xml `-- src `-- main |-- resources `-- webapp |-- WEB-INF | `-- web.xml `-- index.jsp

打开pom.xml,加上Servlet和JSTL依赖。注意javax.servlet-api的scope要设为provided,因为Servlet容器里已经有这些类了,打包的时候不需要塞进war包。

<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>

接下来在src/main/java下面新建包com.demo.controller,写第一个Servlet:

package com.demo.controller; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @WebServlet("/hello") public class HelloServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/html;charset=UTF-8"); resp.getWriter().write("<h1>Hello JavaEE</h1>"); } }

如果想直接用VS Code的Tomcat插件运行,可以右键项目名,选择Run on Tomcat。插件会自动执行mvn package生成war包,然后部署到Tomcat。启动之后浏览器访问http://localhost:8080/bookmanager/hello,能看到页面上出现“Hello JavaEE”就算成功了。

2.5 首次启动踩过的坑

我在这里踩的第一个坑是URL路径不对。war包的名字是bookmanager,所以访问路径必须带上这个项目上下文,否则会404。Tomcat插件也会在侧边栏展开项目名那一层显示context path,注意看一眼就行。

第二个坑是中文乱码。Tomcat 9的控制台和代码里的UTF-8在Windows的默认编码下经常会打架。一个偏方是执行startup.bat前,先在当前终端执行chcp 65001切到UTF-8代码页,或者在启动脚本里加上JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8。代码里则统一用request.setCharacterEncoding("UTF-8"),后面写Filter的时候也可以统一处理。

第三个坑是javax.servlet.http.HttpServlet在VS Code里标红,通常是扩展还没从Tomcat安装路径里读取到Servlet API。检查一下是不是把javax.servlet-api依赖加上了,以及scope是不是provided。如果加上了还标红,就在VS Code命令面板里执行Java: Clean Java Language Server Workspace,让语言服务重置一次。

3. 三个“老伙计”的协作:Servlet、Filter、Listener

3.1 Servlet处理请求的最小可用代码

Servlet是JavaEE Web应用里最核心的组件。它接收HTTP请求、读取参数、执行业务逻辑、返回响应。每一个@WebServlet("/路径")注解或者web.xml里的servlet-mapping,都是告诉容器“这个URL归谁管”。

以登录请求为例,一个简单的LoginServlet大概是这样的:

package com.demo.controller; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); if ("admin".equals(username) && "123456".equals(password)) { req.getSession().setAttribute("admin", username); resp.sendRedirect("index.jsp"); } else { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("login.jsp").forward(req, resp); } } }

这段代码很好地说明了Servlet的职责:从request里取数据,做最简逻辑,再决定是重定向还是转发。你可能会觉得这段代码很稚嫩,但它是后面所有分层的起点。如果一个Servlet里写了大量SQL和HTML拼接,那就是所谓的“面条代码”,这也是安全审计里最让人头疼的东西。

Servlet生命周期里有几个方法值得关注。init()只执行一次,适合初始化连接池之类重资源;service()会根据请求类型自动路由到doGet或者doPost,所以一般不需要重写;destroy()在Servlet从容器卸载时调用,通常用来释放资源。我个人建议在调试阶段重写init()打一行日志,能帮助你感知Servlet的创建时机。

3.2 Filter链:从字符编码到登录鉴权

Filter是位于Servlet之前的拦截器。一个请求到了容器之后,要先经过所有匹配的Filter,最后才会进入Servlet。Filter最常见的用法有三种:统一设置请求/响应的编码、做登录鉴权、记录日志。

字符编码Filter,几乎每个项目都该有:

package com.demo.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import java.io.IOException; @WebFilter("/*") public class CharacterEncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); chain.doFilter(request, response); } }

注意一定要在chain.doFilter(request, response)之前设置编码,因为一旦请求参数被读取,再修改编码就不生效了。

登录鉴权Filter是另一个高频例子。假设管理后台的所有URL都在/admin/*下面,可以这样写:

package com.demo.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; @WebFilter("/admin/*") public class AdminAuthFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq = (HttpServletRequest) request; HttpServletResponse httpResp = (HttpServletResponse) response; HttpSession session = httpReq.getSession(false); if (session == null || session.getAttribute("admin") == null) { httpResp.sendRedirect(httpReq.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }

这段代码的意思是:如果当前会话里没有admin这个属性,说明没登录,直接跳回登录页;否则继续放行。

多个Filter同时存在时,执行顺序是一个值得注意的点。如果用注解配置,顺序不是严格按照类名字来的,后面被加载的Filter排序不一定符合直觉。如果顺序敏感,比如必须先编码Filter再鉴权Filter,我建议直接把Filter定义在web.xml里,因为那里的filter-mapping顺序是明确有效的。

3.3 Listener:应用启动和用户会话的“监听哨”

Listener用来监听Web应用里各种事件。最常见的是ServletContextListener和HttpSessionListener。前者可以在应用启动时初始化东西,后者可以统计在线人数。

下面是一个简单的在线人数统计Listener:

package com.demo.listener; import javax.servlet.ServletContext; import javax.servlet.annotation.WebListener; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionListener; @WebListener public class OnlineCountListener implements HttpSessionListener { @Override public void sessionCreated(HttpSessionEvent se) { ServletContext ctx = se.getSession().getServletContext(); Object online = ctx.getAttribute("online"); int count = online == null ? 0 : (Integer) online; ctx.setAttribute("online", count + 1); } @Override public void sessionDestroyed(HttpSessionEvent se) { ServletContext ctx = se.getSession().getServletContext(); Integer count = (Integer) ctx.getAttribute("online"); if (count != null) { ctx.setAttribute("online", count - 1); } } }

这里有一个比较容易忽视的细节:应用启动时,ServletContext里没有online这个属性,所以取出来是null。写成Integer count = (Integer) ctx.getAttribute("online")之后,多了就加,少了就减,逻辑上不会报错,但如果没做空判断,第一次访问就把线上人数变成null,页面显示就会异常。

我在实际开发里对Listener的使用不算多,主要是因为它解决的是某些特定场景的需求。但理解它的存在很重要:一个Web应用的启动、停止、Session创建、销毁,都是可以编程感知的。

3.4 一次请求从浏览器到响应的完整流转

把Servlet、Filter、Listener放在一起看,一次请求的生命周期就清晰了。假设用户访问的是/admin/book/list,链路大致是:

  1. 容器检查对应的Application Filter链。
  2. 编码Filter先执行,设置请求和响应编码。
  3. 鉴权Filter判断Session里是否有登录信息,没有则重定向。
  4. 请求最终进入BookListServlet的doGet或doPost方法。
  5. Servlet读取参数,调用Service和DAO完成数据查询。
  6. Servlet把数据放到request属性,转发到JSP页面。
  7. JSP渲染结果,经过响应Filter返回给浏览器。

Filter能访问所有请求,Servlet只处理自己映射的路径,Listener则在整个生命周期里感知各种事件。三者分得清清楚楚,这也是JavaEE设计上最经典的地方。

组件职责典型场景
Servlet接收请求,调用业务代码,控制视图跳转登录、列表、表单提交
Filter请求前后统一处理字符编码、登录鉴权、日志记录
Listener监听应用和Session事件启动初始化、在线人数统计

4. 从Servlet乱堆到分层:DAO、Service、Controller的实战重构

4.1 最初图省事的写法为什么撑不到第二个功能

刚开始写JavaEE的时候,很容易把SQL直接写在Servlet里。一个方法动辄五六十行,查完数据库再拼HTML输出到resp.getWriter()。这样写第一个接口很快,第二个接口也能凑合,但到第三个接口碰上复用时就崩了:改一个数据库表结构,所有相关Servlet都要动;想加一个单元测试,Servlet依赖的容器环境很难模拟。

分层并不是什么高级理念,它就是为了让代码“换起来不疼”。我最终的目标是三层结构:Controller层(Servlet)负责接收请求和返回响应;Service层处理业务逻辑;DAO层专门访问数据库。JSP只负责展示,不在里面写大段Java代码。

4.2 用连接池管理数据库连接

之前我只会在Servlet里DriverManager.getConnection,每次请求都新建一个连接,请求结束再关闭。连接创建的开销其实不小,高并发下还会让数据库文件句柄迅速膨胀。连接池是这个场景的标准解法。

我用的是Druid,因为它有监控、性能也不错。做一个简单的工具类:

package com.demo.util; import com.alibaba.druid.pool.DruidDataSource; import javax.sql.DataSource; public class JdbcUtils { private static DataSource dataSource; static { try { DruidDataSource ds = new DruidDataSource(); ds.setUrl("jdbc:mysql://localhost:3306/bookdb?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai"); ds.setUsername("root"); ds.setPassword("your_password"); ds.setInitialSize(5); ds.setMaxActive(20); ds.setMaxWait(60000); dataSource = ds; } catch (Exception e) { throw new RuntimeException("初始化连接池失败", e); } } public static DataSource getDataSource() { return dataSource; } }

要注意serverTimezone=Asia/Shanghai这个参数,不设置的话,新版JDBC驱动在连接MySQL时可能直接报时区错误。URL里的characterEncoding=UTF-8也必须和之前编码Filter的配置保持一致,否则中文又是一个坑。

4.3 写一个BaseDAO和BookDAO

有了连接池之后,DAO层就很容易写了。我用Apache Commons DbUtils简化JDBC样板代码,里面自带QueryRunner和结果集转Bean的功能。

BaseDAO可以这样定义:

package com.demo.dao; import com.demo.util.JdbcUtils; import org.apache.commons.dbutils.QueryRunner; import org.apache.commons.dbutils.handlers.BeanListHandler; import java.sql.SQLException; import java.util.List; public class BaseDAO<T> { private Class<T> clazz; public BaseDAO(Class<T> clazz) { this.clazz = clazz; } public List<T> queryList(String sql, Object... params) throws SQLException { QueryRunner runner = new QueryRunner(JdbcUtils.getDataSource()); return runner.query(sql, new BeanListHandler<>(clazz), params); } }

BookDAO继承BaseDAO,只要写上自己的查询方法:

package com.demo.dao; import com.demo.entity.Book; import java.sql.SQLException; import java.util.List; public class BookDAO extends BaseDAO<Book> { public BookDAO() { super(Book.class); } public List<Book> findAll() throws SQLException { String sql = "SELECT id, name, author, price FROM book"; return queryList(sql); } }

这里点名一个规范:SQL里不要拼接参数。SELECT id, name, author, price FROM book WHERE name =?这种占位符写法,才是在DAO层防范SQL注入的基础。BaseDAO里Object... params拿到参数后,QueryRunner会帮我们正确设置到PreparedStatement中。

4.4 JSP页面里别写Java:EL和JSTL的正确用法

JSP早期允许直接在页面里写<% for(...) { %>,看起来效率很高,但维护成本极低。我强烈建议把JSP当纯模板用,数据由Servlet提前放到request或session里,页面只负责取值和循环。

Servlet端转发到JSP时,把列表放进request:

req.setAttribute("books", bookService.findAll()); req.getRequestDispatcher("/WEB-INF/jsp/book-list.jsp").forward(req, resp);

JSP端用EL表达式和JSTL来渲染:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>图书列表</title> </head> <body> <table border="1"> <tr> <th>编号</th> <th>书名</th> <th>作者</th> <th>价格</th> </tr> <c:forEach items="${books}" var="book"> <tr> <td>${book.id}</td> <td>${book.name}</td> <td>${book.author}</td> <td>${book.price}</td> </tr> </c:forEach> </table> </body> </html>

注意JSP文件我放在了WEB-INF目录下面。因为直接放在webapp下的时候,用户可以不经过Servlet直接访问JSP,这样请求里的业务校验就失效了。放到WEB-INF后,只能通过Servlet转发来访问,这条路才是可控的。

从DAO到Servlet再到JSP,整个分层就串起来了。Controller变得很薄,DAO只负责数据,Service负责交易和业务规则,JSP负责展示。后面想换成Spring MVC也方便,Dao和Service几乎可以原封不动搬过去。

5. 安全视角扫一遍:JavaEE开发中最常见的五个漏洞点

5.1 SQL注入:唯一的解法是参数化查询

写JavaEE接口时最危险的句子就是字符串拼接SQL。比如:

String sql = "SELECT * FROM user WHERE username = '" + username + "' AND password = '" + password + "'";

如果username被传成' OR '1'='1,整个条件就变成恒真,等于绕过了登录。更严重的是,有些数据库驱动支持多语句执行,攻击者可以在参数里塞一条DELETE语句,结果就不只是脱库,而是直接删表。

正确写法是PreparedStatement。哪怕你用原生JDBC,也应当这样:

String sql = "SELECT * FROM user WHERE username = ? AND password = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password);

?占位符会把参数当纯数据,而不是SQL结构的一部分。这也是我在前面写BaseDAO时坚持用QueryRunner加可变参数的原因。代码审计里看到Statement.executeQuery(String sql)、Statement.execute(String sql),几乎可以直接标红。

5.2 XSS:不只是“过滤”,而是输出编码

XSS的本质是服务端把用户输入的数据当成HTML标签输出了。比如用户注册了一个昵称叫<script>alert(1)</script>,页面用${user.nickname}直接渲染,这段脚本就会执行。

防御的第一道防线是输出编码。JSTL的<c:out>默认会对HTML特殊字符转义,应该优先使用:

<c:out value="${user.nickname}"/>

而不是直接写${user.nickname}。如果业务上确实需要富文本,你得用白名单过滤器,比如OWASP Java HTML Sanitizer,而不是自己写正则去replace。黑名单永远不可靠,因为浏览器对实体编码、大小写、事件属性的解析方式太多样了。

5.3 CSRF:同步令牌在Filter里的落地

CSRF利用的是浏览器自动携带Cookie特性:用户登录了银行系统,又访问了一个恶意站点,恶意站点发起的请求会带上银行的Cookie,银行后端不知道请求来源有问题,照样执行转账操作。

防御CSRF最简单有效的方式是同步令牌。用户登录后,在Session里保存一个随机token,每次表单提交时把token作为隐藏字段带上;后端在Filter里校验这个token和Session里的是否一致。

登录成功生成token:

String token = UUID.randomUUID().toString(); req.getSession().setAttribute("csrf_token", token); req.setAttribute("csrf_token", token);

表单里加隐藏字段:

<input type="hidden" name="csrf_token" value="${csrf_token}" />

Filter里过滤所有POST请求:

if ("POST".equals(req.getMethod())) { HttpSession session = req.getSession(false); String sessionToken = session == null ? null : (String) session.getAttribute("csrf_token"); String requestToken = req.getParameter("csrf_token"); if (sessionToken == null || !sessionToken.equals(requestToken)) { resp.sendError(403); return; } }

这样即使恶意站点头表单发出了请求,也拿不到你Session里的随机token,请求就会被拦下。

5.4 文件上传和路径穿越

文件上传接口如果只检查了文件的Content-Type,很容易被绕过。比如测试工具把image/png改成application/octet-stream,或者直接用一个jpg后缀的webshell伪装文件。真正要做的检查是扩展名白名单,并且不止看文件名后缀,还要验证文件内容头部是否匹配。

另一个容易忽略的是路径穿越。很多人在Servlet里写:

String path = "/upload/" + filename; FileOutputStream fos = new FileOutputStream(path);

如果filename带着../../,文件就能被写到上层目录,甚至写到启动脚本目录里。有效做法是:上传目录放在webroot之外,用UUID重命名文件,再将文件扩展名白名单化。

5.5 Session安全问题的小提醒

Session是JavaEE里一个高频目标。登录成功后,要调用一下旧Session失效,然后创建一个新的,防止Session Fixation攻击:

HttpSession oldSession = req.getSession(false); if (oldSession != null) { oldSession.invalidate(); } HttpSession newSession = req.getSession(true); newSession.setAttribute("admin", username);

Cookie属性也要设置HttpOnly和Secure。HttpOnly能防止JavaScript读取Cookie里的Session ID,Secure让Cookie只在HTTPS下传输。Filter换Session的时候,这些属性也得同步更新。

Session超时时间不要设太长,管理后台建议15到20分钟。超时时间越长,Session被窃取后的有效攻击窗口就越大。

漏洞类型产生位置关键防御写法
SQL注入DAO层SQL拼接PreparedStatement / 占位符
XSSJSP输出用户内容JSTL c:out / 白名单过滤
CSRF表单提交同步令牌 + Filter校验
文件上传上传接口扩展名白名单 + UUID重命名
会话固定登录逻辑登录后invalidate旧Session

6. 实战复盘:从部署调试到收尾的个人体会

6.1 热部署与日志:别让每次重启消耗掉耐心

纯Servlet项目改一行代码就要重启Tomcat,很影响学习效率。开发阶段可以在Tomcat里把Context的reloadable设为true,这样类文件变更后容器会自动重新加载。VS Code的Tomcat插件也提供了一个更新按钮,相当于手动触发reload。

但注意这个配置不能带到生产环境。生产环境的reloadable必须关掉,否则任意一个小改动都可能触发大规模上下文重载,请求全部超时。

日志方面,用System.out打印消息在控制台看看还行,项目稍微大一点就很难定位。建议尽早使用SLF4J的Simple实现,或者配置Log4j2。输出里最好带上请求路径和耗时,比如在Filter里记录一条req.getRequestURI()加耗时日志,排错会顺手很多。

6.2 调优连接池参数和Tomcat线程池

连接池参数不是越大越好。数据库连接过多,反而会造成数据库线程切换和锁竞争加剧。我一般按并发量来估:同时在线200用户,连接池maxActive设为20就够了。initialSize设5,让启动时有几个连接预热。maxWait设60000毫秒,拿不到连接时等待一分钟再报错,避免应用直接卡死。

Tomcat自身的线程池也需要了解。默认情况下maxThreads是200,如果请求量大,可以结合CPU核数适当调整,但别盲目设置成几千。线程多了之后,上下文切换带来的开销比等待数据库返回还大。

6.3 学完这段后,我对“安全开发”的理解

这段课程给我最大的收获不是记住了几个组件API,而是建立了一个“请求链路思维”。以前看安全报告,看到漏洞在/admin/upload,我会觉得很抽象;现在我能立刻想到这条请求经过哪些Filter,Servlet里是怎么读参数的,DAO层是怎么处理SQL的,文件到底落在了哪个目录。

安全开发并不是要在写完代码后额外做一遍什么神秘测试,而是在写每一行代码时就想清楚它会不会被恶意输入影响。JavaEE里这些“老组件”本身没什么问题,问题往往出在开发者图省事:字符串拼接SQL、不过滤直接渲染、上传文件不校验。当你把前面的步骤都做规范,很多漏洞在源头就消失了。

如果你也在学JavaEE,我强烈建议不要只停留在跑通,而是每学一个组件就故意写一个有漏洞的版本和一个安全版本,对比一下差别。这个过程比看十篇博客都有用。day32~40这一段是我过去这段时间里收获最大的一次补课,它让我真正看懂了从request到response之间那些“看不见的代码”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询