Langchain-Chatchat SQL注入防护:MyBatis防攻击最佳实践
在构建企业级本地知识库问答系统时,安全往往不是最显眼的需求,却是最不能妥协的底线。Langchain-Chatchat 作为基于大语言模型(LLM)和 LangChain 框架的开源私有化智能问答平台,因其数据处理完全在本地完成、不依赖外部API,成为许多对合规性与隐私保护有高要求场景的首选方案。然而,一旦涉及数据库操作——比如存储文档元信息、会话记录或索引状态——就不可避免地打开了潜在的攻击面,尤其是SQL注入。
这个老生常谈却又屡禁不止的安全漏洞,至今仍稳居 OWASP Top 10 榜单前列。而 Langchain-Chatchat 在持久层广泛采用 MyBatis,既带来了灵活控制 SQL 的优势,也埋下了因误用导致注入风险的隐患。关键问题在于:如何在保留 MyBatis 动态能力的同时,确保每一行执行的 SQL 都是安全可控的?
答案并不复杂:正确的参数绑定方式 + 严格的输入校验 + 合理的设计约束。接下来,我们从实际开发视角出发,深入剖析这套组合拳是如何落地的。
MyBatis 的核心价值,在于它不像 Hibernate 那样试图“全自动”映射对象关系,而是让开发者直接编写原生 SQL,同时又屏蔽了 JDBC 中繁琐的资源管理和参数设置过程。这种“半自动化”的设计,特别适合像 Langchain-Chatchat 这类需要频繁进行复杂查询、分页检索、条件拼接的知识库系统。
它的基本工作流程非常清晰:
- 系统启动时加载
mybatis-config.xml,初始化SqlSessionFactory; - 定义 Mapper 接口,通过 XML 或注解将方法与 SQL 关联;
- 调用 Mapper 方法时,MyBatis 自动生成
PreparedStatement,自动设置参数并执行; - 将结果集映射为 Java 对象返回。
真正起到防护作用的关键环节,正是第三步中的参数处理机制。当使用#{param}占位符时,MyBatis 底层会调用 JDBC 的预编译语句(PreparedStatement),将用户输入作为纯数据传入,而非 SQL 文本的一部分。这意味着即使输入包含' OR '1'='1这样的经典注入 payload,也不会改变 SQL 的语法结构。
举个例子:
<select id="selectByTitle" resultType="Document"> SELECT id, title, file_path, upload_time FROM documents WHERE title = #{title} </select>这段代码最终会被转化为类似如下的 Java 执行逻辑:
PreparedStatement ps = connection.prepareStatement( "SELECT id, title, file_path, upload_time FROM documents WHERE title = ?"); ps.setString(1, userInput); // 用户输入被当作字符串值处理无论userInput是"年度报告"还是"年度报告' OR '1'='1",数据库都只会将其视为一个完整的字符串条件去匹配,不会将其解析为额外的 SQL 命令。这就是为什么#{}是安全的根基。
但问题往往出在另一个符号上:${}。
与#{}不同,${}是纯粹的字符串替换,发生在 SQL 解析之前。例如:
<select id="queryFromTable" resultType="Document"> SELECT * FROM ${tableName} WHERE status = #{status} </select>如果tableName来自用户请求参数且未经任何校验,攻击者完全可以传入"documents; DROP TABLE users;",从而触发灾难性的后果。虽然 MyBatis 提供了动态 SQL 标签来避免手动拼接,但${}的存在依然为误用留下了空间。
所以一个铁律必须牢记:永远不要让${}接收不可信输入。如果你确实需要动态表名、排序字段或数据库对象名,唯一的做法是引入白名单机制。
比如可以定义一个枚举类来限定合法的表名:
public enum ValidTable { DOC_USER("user_docs"), DOC_PUBLIC("public_docs"); private final String tableName; ValidTable(String tableName) { this.tableName = tableName; } public String getTableName() { return tableName; } public static boolean isValid(String input) { return Arrays.stream(values()) .anyMatch(t -> t.name().equalsIgnoreCase(input) || t.tableName.equals(input)); } }然后在 Service 层做前置校验:
@Service public class DocumentService { public List<Document> queryFromTable(String rawTableName, String status) { if (!ValidTable.isValid(rawTableName)) { throw new IllegalArgumentException("Invalid table name: " + rawTableName); } String validatedTableName = ValidTable.valueOf(rawTableName.toUpperCase()).getTableName(); return documentMapper.queryFromTable(validatedTableName, status); } }这样即便接口暴露,非法输入也会被提前拦截,从根本上杜绝了利用${}实现注入的可能性。
再来看更常见的场景:多条件组合查询。比如用户希望根据标题、状态、上传时间等多个维度筛选文档。很多人第一反应是在 Java 代码里拼 SQL 字符串,但这正是危险的开始。
正确的方式是充分利用 MyBatis 提供的动态标签,如<if>、<where>、<trim>和<foreach>。它们不仅能生成干净的 SQL,还能保证所有变量仍然通过#{}绑定。
<select id="searchDocuments" parameterType="map" resultType="Document"> SELECT id, title, file_path, upload_time FROM documents <where> <if test="title != null and title != ''"> AND title LIKE CONCAT('%', #{title}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="startTime != null"> AND upload_time >= #{startTime} </if> </where> ORDER BY upload_time DESC </select>这里的<where>标签非常聪明:它会自动判断内部是否有有效条件,如果有,则插入WHERE关键字;如果没有,则整个忽略。同时还会自动去除多余的AND或OR。这比手动拼接字符串要可靠得多。
对于批量操作,比如删除多个文档 ID,也应该使用<foreach>而非循环调用单条 SQL:
<delete id="batchDeleteByIds"> DELETE FROM documents WHERE id IN <foreach item="id" collection="list" open="(" separator="," close=")"> #{id} </foreach> </delete>这种方式不仅性能更好(减少网络往返),而且每一条#{id}依然是预编译参数,安全性不受影响。
当然,光靠编码规范还不够。工程实践中还需要一系列辅助手段来加固防线。
首先是输入校验。不要指望前端过滤能挡住攻击者,所有进入后端的参数都应被视为潜在威胁。结合 Spring Validation,在 Controller 或 Service 入参处进行基础校验是一种低成本高回报的做法:
public class DocumentQueryRequest { @Size(max = 100, message = "标题长度不得超过100字符") private String title; @Pattern(regexp = "^(active|inactive)?$", message = "状态只能为 active 或 inactive") private String status; // getter/setter... }其次是日志审计。建议开启 MyBatis 的 SQL 日志输出(可通过log4j2或slf4j配置),记录实际执行的 SQL 及参数值。这对于排查异常行为、追溯攻击路径至关重要。不过要注意脱敏处理,避免敏感信息写入日志文件。
另外,静态代码扫描工具也应纳入 CI/CD 流程。像 SonarQube、FindSecBugs 这类工具能够自动检测项目中是否存在${}被用于接收用户输入的情况,及时发出警告。
最后但同样重要的是权限最小化原则。数据库连接账号不应拥有DROP、ALTER、SHUTDOWN等高危权限,最好只授予SELECT、INSERT、UPDATE、DELETE等基本操作权限。即使发生注入,也能将损失控制在有限范围内。
回到 Langchain-Chatchat 的典型架构中,其数据流通常是这样的:
[前端/UI] ↓ (HTTP API) [Spring Boot Controller] ↓ (业务逻辑) [Service Layer] ↓ (数据操作) [MyBatis Mapper] → [Database]每当用户上传一份 PDF 并发起提问时,系统都会提取元数据存入数据库,并根据关键词检索相关文档。这些看似普通的 CRUD 操作背后,隐藏着无数可能被利用的入口点。
设想这样一个 URL 请求:
/api/documents?title=' UNION SELECT password, 1, 2 FROM users --如果后端使用字符串拼接构造 SQL,攻击者就有可能通过联合查询(UNION-based injection)窃取其他表中的敏感信息。但如果始终坚持使用#{}参数绑定,这条恶意语句就会被当作一个普通的字符串条件去匹配,自然无法得逞。
这也解释了为什么在该类系统中,应用层的编码实践比外围 WAF 更可靠。WAF 虽然能拦截部分已知模式的攻击,但对于编码绕过、分段注入等高级手法常常力不从心。而从代码源头杜绝漏洞,则实现了真正的根因治理。
更重要的是,这种安全策略几乎没有性能代价。相反,由于PreparedStatement支持 SQL 执行计划缓存,合理使用还能提升查询效率。再加上 MyBatis 本身对多种数据库的良好兼容性(MySQL、PostgreSQL、SQLite 等),使得这一套方案极具普适性和可复制性。
归根结底,SQL 注入防护不是一个“加功能”的问题,而是一个“守底线”的问题。在 Langchain-Chatchat 这类强调私密性与可控性的系统中,数据库安全是整个信任链条的基石。一旦失守,再强大的 AI 能力也将沦为攻击者的帮凶。
通过坚持使用#{}参数绑定、禁用${}处理用户输入、善用动态标签替代字符串拼接、配合白名单与输入校验,开发者完全可以在不影响功能灵活性的前提下,构建出高度抗攻击的数据访问层。
这不仅是对技术细节的把控,更是对工程责任的践行。毕竟,真正的智能,从来都不是以牺牲安全为代价换来的。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考