diff --git a/.gitignore b/.gitignore index 36c69f0..7540cf0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ target/ *.class *.jar +dist/ # 运行时目录(本地联调用,不入 Git) bin/ diff --git a/src/main/java/com/hospital/front/infra/IpWhitelistFilter.java b/src/main/java/com/hospital/front/infra/IpWhitelistFilter.java new file mode 100644 index 0000000..67cf7f9 --- /dev/null +++ b/src/main/java/com/hospital/front/infra/IpWhitelistFilter.java @@ -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 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 pool() { + String raw = Solon.cfg().get("security.ipWhitelist", ""); + Set 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; // 网段配置非法视为不匹配 + } + } +} diff --git a/src/main/java/com/hospital/front/infra/exception/GlobalExceptionHandler.java b/src/main/java/com/hospital/front/infra/exception/GlobalExceptionHandler.java index 316041d..89d5875 100644 --- a/src/main/java/com/hospital/front/infra/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/hospital/front/infra/exception/GlobalExceptionHandler.java @@ -23,6 +23,10 @@ public class GlobalExceptionHandler implements Filter { public void doFilter(Context ctx, FilterChain chain) { try { 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) { String msg = e.getMessage() != null ? 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":...})。 */ diff --git a/src/main/resources/app.yml b/src/main/resources/app.yml index a577051..692ff17 100644 --- a/src/main/resources/app.yml +++ b/src/main/resources/app.yml @@ -44,6 +44,20 @@ order: admin: 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 为唯一数据源,随医院配置注入) # mappers 条目两种形式(Solon mybatis 约定):