Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions SECURITY_CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,21 @@

```properties
# 方式1:通过配置文件
trust.host = kkview.cn,yourdomain.com,cdn.example.com
trust.host = kkview.cn,yourdomain.com,cdn.example.com,*.cdn.example.com

# 方式2:通过环境变量
KK_TRUST_HOST=kkview.cn,yourdomain.com,cdn.example.com
KK_TRUST_HOST=kkview.cn,yourdomain.com,cdn.example.com,*.cdn.example.com
```

**示例场景**:
- 只允许预览来自 `oss.aliyuncs.com` 和 `cdn.example.com` 的文件
```properties
trust.host = oss.aliyuncs.com,cdn.example.com
```
- 允许预览来自 `example.com` 的域名和 `example.com` 与 `cdn.example.com` 子域名的文件
```properties
trust.host = example.com,*.example.com,*.cdn.example.com
```

### 2. 允许所有主机(不推荐,仅测试环境)

Expand All @@ -41,7 +45,7 @@ trust.host = *
not.trust.host = localhost,127.0.0.1,192.168.*,10.*,172.16.*,169.254.*

# 禁止特定恶意域名
not.trust.host = malicious-site.com,spam-domain.net
not.trust.host = malicious-site.com,spam-domain.net,*.spam-domain.net
```

**优先级**:黑名单 > 白名单
Expand All @@ -62,7 +66,7 @@ docker run -d \

```properties
# 1. 明确配置信任主机白名单
trust.host = your-cdn.com,your-storage.com
trust.host = your-cdn.com,your-storage.com,*.your-storage.com

# 2. 配置黑名单防止内网访问
not.trust.host = localhost,127.0.0.1,192.168.*,10.*,172.16.*
Expand Down Expand Up @@ -146,9 +150,8 @@ trust.host = *

### Q4: 如何允许子域名?

目前不支持通配符域名匹配,需要明确列出每个子域名:
```properties
trust.host = cdn.example.com,api.example.com,storage.example.com
trust.host = *.example.com,*.cdn.example.com
```

## 🚨 安全事件响应
Expand Down
2 changes: 1 addition & 1 deletion server/src/main/config/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ base.url = ${KK_BASE_URL:default}
# ⚠️ 如果不配置,系统将默认拒绝所有外部文件预览请求
#
# 配置示例:
# trust.host = kkview.cn,yourdomain.com,cdn.example.com
# trust.host = kkview.cn,yourdomain.com,cdn.example.com,*.cdn.example.com
#
# 如果需要允许所有域名(不推荐,仅用于测试环境),请设置为:
# trust.host = *
Expand Down
265 changes: 265 additions & 0 deletions server/src/main/java/cn/keking/utils/DomainIpMatcherUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
package cn.keking.utils;

import java.util.Set;
import java.util.regex.Pattern;

/**
* @author mks155
* @date 2026/1/22
* @description 域名/IP匹配工具
* 支持:*.example.com, example.com, localhost, 127.0.0.1, 192.168.*, 172.16.*, 10.*
*/
public final class DomainIpMatcherUtil {

// IPv4地址正则
private static final Pattern IPV4_PATTERN =
Pattern.compile("^(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$");

// 域名正则
private static final Pattern DOMAIN_PATTERN =
Pattern.compile("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$");

private static final String WILDCARD_PREFIX = "*.";

/**
* 检查域名/IP是否在允许的配置列表中
*/
public static boolean isAllowed(Set<String> allowedPatterns, String host) {
if (allowedPatterns == null || allowedPatterns.isEmpty() || host == null) {
return false;
}

String trimmedHost = host.trim();
if (trimmedHost.isEmpty()) {
return false;
}

for (String pattern : allowedPatterns) {
if (pattern == null) continue;

String trimmedPattern = pattern.trim();
if (trimmedPattern.isEmpty()) continue;

if (matchPattern(trimmedPattern, trimmedHost)) {
return true;
}
}

return false;
}

/**
* 匹配单个模式
*/
private static boolean matchPattern(String pattern, String host) {
// 1. 精确匹配(需要验证格式,防止无效域名/IP被精确匹配)
if (pattern.equals(host)) {
// 只有格式合法才允许精确匹配
return isValidDomain(pattern) || isIpFormat(pattern) || isSpecialDomain(pattern);
}

// 2. IP段匹配(10.*, 192.168.*, 192.168.1.*)
if (isIpSegmentPattern(pattern)) {
return matchIpSegment(pattern, host);
}

// 3. 通配符域名(*.example.com)
if (pattern.startsWith(WILDCARD_PREFIX)) {
return matchWildcardDomain(pattern, host);
}

// 4. 精确IP匹配(已在第一步处理)
if (isIpFormat(pattern) && isIpFormat(host)) {
return false;
}

// 5. 特殊域名(localhost, 127.0.0.1)
if (isSpecialDomain(pattern)) {
return isSpecialDomain(host) && pattern.equalsIgnoreCase(host);
}

return false;
}

/**
* 匹配通配符域名:*.example.com
* 规则:
* - 必须以 .suffix 结尾(不区分大小写)
* - 不能等于suffix
* - 子域名部分必须有效(防止evil.com.example.com)
* - 只能匹配一级子域名(a.example.com 可以,a.b.example.com 不行)
* - 域名不区分大小写
*/
private static boolean matchWildcardDomain(String pattern, String domain) {
// 去掉 "*." 并转换为小写
String suffix = pattern.substring(2).toLowerCase();
String domainLower = domain.toLowerCase();

// 验证后缀格式
if (!isValidDomain(suffix)) {
return false;
}

// 必须以 .suffix 结尾
if (!domainLower.endsWith("." + suffix)) {
return false;
}

// 不能等于suffix
if (domainLower.equals(suffix)) {
return false;
}

// 提取子域名部分
String subdomain = domainLower.substring(0, domainLower.length() - suffix.length() - 1);

// 防止多级子域名:a.b.example.com 不应匹配 *.example.com
if (subdomain.contains(".")) {
return false;
}

// 验证子域名(防止evil.com.example.com)
return isValidSubdomain(subdomain);
}

/**
* 匹配IP段:10.*, 192.168.*, 192.168.1.*
* 支持2、3、4段格式
*/
private static boolean matchIpSegment(String pattern, String host) {
if (!isIpFormat(host)) {
return false;
}

String[] patternParts = pattern.split("\\.");
String[] hostParts = host.split("\\.");

// 支持2、3、4段pattern匹配4段host
if (hostParts.length != 4) {
return false;
}

// 只比较pattern的段数,pattern的每一段对应host的对应段
for (int i = 0; i < patternParts.length; i++) {
if ("*".equals(patternParts[i])) {
continue;
}
if (!patternParts[i].equals(hostParts[i])) {
return false;
}
}
return true;
}

/**
* 判断是否是IP段模式(10.*, 192.168.*, 192.168.1.*)
* 支持2、3、4段格式
*/
private static boolean isIpSegmentPattern(String str) {
if (str == null || !str.contains("*")) {
return false;
}

String[] parts = str.split("\\.");
// 支持2、3、4段:10.*, 192.168.*, 192.168.1.*
if (parts.length < 2 || parts.length > 4) {
return false;
}

for (String part : parts) {
if ("*".equals(part)) {
continue;
}
if (!isNumeric(part)) {
return false;
}
int num = Integer.parseInt(part);
if (num < 0 || num > 255) {
return false;
}
}
return true;
}

/**
* 判断是否是标准IPv4格式
*/
private static boolean isIpFormat(String str) {
return str != null && IPV4_PATTERN.matcher(str).matches();
}

/**
* 判断是否是特殊域名(localhost, 127.0.0.1)
*/
private static boolean isSpecialDomain(String str) {
if (str == null) {
return false;
}
return "localhost".equalsIgnoreCase(str) || "127.0.0.1".equals(str);
}

/**
* 验证域名格式
*/
private static boolean isValidDomain(String domain) {
if (domain == null || domain.isEmpty() || domain.length() > 253) {
return false;
}

// 特殊域名直接通过
if (isSpecialDomain(domain)) {
return true;
}

// 格式检查
if (domain.startsWith(".") || domain.endsWith(".") || domain.contains("..")) {
return false;
}

String[] parts = domain.split("\\.");
for (String part : parts) {
if (part.isEmpty() || part.length() > 63) {
return false;
}
if (part.startsWith("-") || part.endsWith("-")) {
return false;
}
if (!DOMAIN_PATTERN.matcher(part).matches()) {
return false;
}
}
return true;
}

/**
* 验证子域名(防止绕过)
*/
private static boolean isValidSubdomain(String subdomain) {
if (subdomain == null || subdomain.isEmpty()) {
return false;
}
if (subdomain.contains("*") || subdomain.contains(" ") || subdomain.contains("@") || subdomain.contains(";")) {
return false;
}
if (subdomain.replace(".", "").isEmpty()) {
return false;
}
return isValidDomain(subdomain);
}

/**
* 判断是否是数字
*/
private static boolean isNumeric(String str) {
if (str == null || str.isEmpty()) {
return false;
}
for (char c : str.toCharArray()) {
if (!Character.isDigit(c)) {
return false;
}
}
return true;
}

}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package cn.keking.web.filter;

import cn.keking.config.ConfigConstants;
import cn.keking.utils.DomainIpMatcherUtil;
import cn.keking.utils.WebUtils;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
Expand Down Expand Up @@ -56,7 +58,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
public boolean isNotTrustHost(String host) {
// 如果配置了黑名单,优先检查黑名单
if (CollectionUtils.isNotEmpty(ConfigConstants.getNotTrustHostSet())) {
return ConfigConstants.getNotTrustHostSet().contains(host);
return DomainIpMatcherUtil.isAllowed(ConfigConstants.getNotTrustHostSet(), host);
}

// 如果配置了白名单,检查是否在白名单中
Expand All @@ -66,7 +68,7 @@ public boolean isNotTrustHost(String host) {
logger.debug("允许所有主机访问(通配符模式): {}", host);
return false;
}
return !ConfigConstants.getTrustHostSet().contains(host);
return !DomainIpMatcherUtil.isAllowed(ConfigConstants.getTrustHostSet(), host);
}

// 安全加固:默认拒绝所有未配置的主机(防止SSRF攻击)
Expand Down
Loading