feat: 前置机安全加固(404 路由降级 INFO + IP 白名单过滤器)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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 / ::1(Agent 环回访问 /api/admin)永远放行,无需配置
|
||||||
|
* - 直连部署:取连接对端 IP(remoteIp)判定
|
||||||
|
* - nginx 反代部署(security.trustProxy=true):连接对端必须是环回或 nginx 所在 IP,
|
||||||
|
* 然后取 X-Forwarded-For 的最右侧 IP 判定(最右侧由可信 nginx 追加,无法伪造;
|
||||||
|
* 不能取最左侧——客户端可自带 XFF 冒充白名单)
|
||||||
|
* - 支持三种写法:精确 IP(10.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":...})。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ order:
|
|||||||
admin:
|
admin:
|
||||||
token: ""
|
token: ""
|
||||||
|
|
||||||
|
# IP 白名单(应对端口暴露公网被扫描器探测;环回地址 Agent 永远放行)
|
||||||
|
# 开启后非白名单 IP 一律 403。
|
||||||
|
# ipWhitelist 三种写法可混用,逗号分隔:
|
||||||
|
# 精确 IP:10.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 约定):
|
||||||
|
|||||||
Reference in New Issue
Block a user