feat: 前置机安全加固(404 路由降级 INFO + IP 白名单过滤器)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-09 09:35:22 +08:00
co-authored by Claude Code
parent b7283db0bd
commit ae2b585df9
4 changed files with 202 additions and 0 deletions
+1
View File
@@ -2,6 +2,7 @@
target/ target/
*.class *.class
*.jar *.jar
dist/
# 运行时目录(本地联调用,不入 Git) # 运行时目录(本地联调用,不入 Git)
bin/ bin/
@@ -0,0 +1,174 @@
package com.hospital.front.infra;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import org.noear.solon.Solon;
import org.noear.solon.annotation.Component;
import org.noear.solon.core.handle.Context;
import org.noear.solon.core.handle.Filter;
import org.noear.solon.core.handle.FilterChain;
import lombok.extern.slf4j.Slf4j;
/**
* IP 白名单过滤器(应对前置机端口被公网扫描器探测)。
*
* 配置(app.yml,支持医院配置覆盖):
* security:
* ipWhitelistEnabled: false # 白名单开关(默认关,行为不变)
* ipWhitelist: "10.10.10.228,10.0.0.0/24" # IP 池,逗号分隔
*
* 规则:
* - 开关关闭时全放行(默认)
* - 127.0.0.1 / ::1Agent 环回访问 /api/admin)永远放行,无需配置
* - 直连部署:取连接对端 IP(remoteIp)判定
* - nginx 反代部署(security.trustProxy=true):连接对端必须是环回或 nginx 所在 IP,
* 然后取 X-Forwarded-For 的最右侧 IP 判定(最右侧由可信 nginx 追加,无法伪造;
* 不能取最左侧——客户端可自带 XFF 冒充白名单)
* - 支持三种写法:精确 IP10.0.0.5)、通配段(112.113.*.*,每段可单独 *)、
* CIDR 网段(10.0.0.0/24
* - 非白名单一律 403 打回,并记 WARN 一行(含来源 IP 与路径,便于追踪扫描源)
*/
@Slf4j
@Component
public class IpWhitelistFilter implements Filter {
/** 环回地址(永远放行) */
private static final Set<String> LOOPBACK = new HashSet<>(Arrays.asList("127.0.0.1", "0:0:0:0:0:0:0:1", "::1"));
@Override
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
if (!enabled()) {
chain.doFilter(ctx);
return;
}
String peer = ctx.remoteIp() != null ? ctx.remoteIp() : "";
String ip = peer;
if (trustProxy()) {
// 反代模式:对端必须是环回(同机 nginx)或白名单中的代理 IP,
// 否则视为伪造直连,直接打回
if (!LOOPBACK.contains(peer)) {
log.warn("[IP白名单] 代理模式下非可信代理直连:ip={} {} {}", peer, ctx.method(), ctx.path());
reject(ctx, peer);
return;
}
// X-Forwarded-For 最右侧为本机 nginx 追加的真实客户端 IP(不可伪造)
String xff = ctx.header("X-Forwarded-For");
if (xff != null && !xff.trim().isEmpty()) {
String[] hops = xff.split(",");
ip = hops[hops.length - 1].trim();
}
// 无 XFF 的环回请求 = Agent 本机访问,保持环回 IP(放行)
}
if (isAllowed(ip)) {
chain.doFilter(ctx);
return;
}
reject(ctx, ip);
}
/** 非白名单:403 打回(一行日志,避免扫描器刷爆日志) */
private void reject(Context ctx, String ip) {
log.warn("[IP白名单] 拒绝访问:ip={} {} {}", ip, ctx.method(), ctx.path());
ctx.status(403);
ctx.contentType("application/json;charset=UTF-8");
ctx.output("{\"success\":false,\"msg\":\"禁止访问\",\"data\":null}");
}
/** 白名单开关(惰性读取,支持热更新) */
private boolean enabled() {
return "true".equals(Solon.cfg().get("security.ipWhitelistEnabled", "false"));
}
/** 代理模式开关:nginx 反代部署时置 true,从 X-Forwarded-For 取真实客户端 IP */
private boolean trustProxy() {
return "true".equals(Solon.cfg().get("security.trustProxy", "false"));
}
/** IP 是否放行:环回 / 精确匹配 / 通配段匹配 / CIDR 网段匹配 */
private boolean isAllowed(String ip) {
if (LOOPBACK.contains(ip)) {
return true;
}
for (String entry : pool()) {
if (entry.contains("/")) {
if (cidrMatch(ip, entry)) {
return true;
}
} else if (entry.contains("*")) {
if (wildcardMatch(ip, entry)) {
return true;
}
} else if (entry.equals(ip)) {
return true;
}
}
return false;
}
/**
* 通配段匹配(仅 IPv4):entry 每段可为 * 或具体数字,如 112.113.*.*。
*/
private static boolean wildcardMatch(String ip, String entry) {
String[] ipParts = ip.split("\\.");
String[] entryParts = entry.split("\\.");
if (ipParts.length != 4 || entryParts.length != 4) {
return false;
}
for (int i = 0; i < 4; i++) {
if (!"*".equals(entryParts[i]) && !entryParts[i].equals(ipParts[i])) {
return false;
}
}
return true;
}
/** 白名单 IP 池(逗号分隔,惰性读取) */
private Set<String> pool() {
String raw = Solon.cfg().get("security.ipWhitelist", "");
Set<String> set = new HashSet<>();
if (raw != null && !raw.trim().isEmpty()) {
for (String s : raw.split(",")) {
if (!s.trim().isEmpty()) {
set.add(s.trim());
}
}
}
return set;
}
/** IPv4 CIDR 网段匹配(如 10.0.0.0/24 */
private static boolean cidrMatch(String ip, String cidr) {
try {
String[] parts = cidr.split("/");
byte[] net = java.net.InetAddress.getByName(parts[0]).getAddress();
int prefix = Integer.parseInt(parts[1]);
byte[] addr = java.net.InetAddress.getByName(ip).getAddress();
if (net.length != addr.length || net.length != 4) {
return false; // 仅支持 IPv4 网段
}
int fullBytes = prefix / 8;
int restBits = prefix % 8;
for (int i = 0; i < fullBytes; i++) {
if (net[i] != addr[i]) {
return false;
}
}
if (restBits > 0 && fullBytes < 4) {
int mask = 0xFF << (8 - restBits);
if ((net[fullBytes] & mask) != (addr[fullBytes] & mask)) {
return false;
}
}
return true;
} catch (Exception e) {
return false; // 网段配置非法视为不匹配
}
}
}
@@ -23,6 +23,10 @@ public class GlobalExceptionHandler implements Filter {
public void doFilter(Context ctx, FilterChain chain) { public void doFilter(Context ctx, FilterChain chain) {
try { try {
chain.doFilter(ctx); chain.doFilter(ctx);
} catch (org.noear.solon.core.exception.StatusException e) {
// 404/405 等路由状态异常:多为扫描器探测,INFO 一行即可(不打堆栈,避免刷爆日志)
log.info("路由未匹配:{} {}HTTP {}", ctx.method(), ctx.path(), e.getCode());
renderStatus(ctx, e.getCode());
} catch (BusinessException e) { } catch (BusinessException e) {
String msg = e.getMessage() != null ? e.getMessage() : "业务异常"; String msg = e.getMessage() != null ? e.getMessage() : "业务异常";
log.warn("业务异常:{}", e.getMessage()); log.warn("业务异常:{}", e.getMessage());
@@ -42,6 +46,15 @@ public class GlobalExceptionHandler implements Filter {
} }
} }
/**
* 输出路由状态异常(404/405 等)响应。
*/
private void renderStatus(Context ctx, int code) {
ctx.status(code);
ctx.contentType("application/json;charset=UTF-8");
ctx.output("{\"success\":false,\"msg\":\"Not Found\",\"data\":null}");
}
/** /**
* 输出 Result JSON 响应(与原 hospitalmiddle 契约一致:{"success":false,"msg":...})。 * 输出 Result JSON 响应(与原 hospitalmiddle 契约一致:{"success":false,"msg":...})。
*/ */
+14
View File
@@ -44,6 +44,20 @@ order:
admin: admin:
token: "" token: ""
# IP 白名单(应对端口暴露公网被扫描器探测;环回地址 Agent 永远放行)
# 开启后非白名单 IP 一律 403。
# ipWhitelist 三种写法可混用,逗号分隔:
# 精确 IP10.0.0.5
# 通配段:112.113.*.*(每段可单独 *
# CIDR 网段:10.0.0.0/24
security:
ipWhitelistEnabled: false
ipWhitelist: ""
# ipWhitelist: "112.113.*.*,10.20.30.40,10.0.0.0/24"
# nginx 反代部署时开启:从 X-Forwarded-For 最右侧取真实客户端 IP 判定
# (对端必须是本机环回的 nginx,防止客户端伪造 XFF 绕过白名单)
# trustProxy: false
# ==================================== # ====================================
# MyBatis-Plus 公共配置(db1 为唯一数据源,随医院配置注入) # MyBatis-Plus 公共配置(db1 为唯一数据源,随医院配置注入)
# mappers 条目两种形式(Solon mybatis 约定): # mappers 条目两种形式(Solon mybatis 约定):