From 1ed193f2b6afbd9a4f1678f578ce95037484f735 Mon Sep 17 00:00:00 2001 From: hexqi Date: Tue, 28 Apr 2026 21:31:46 +0800 Subject: [PATCH 1/3] fix: prevent SSRF in AI chat endpoints via URL validation --- app/src/main/resources/application-alpha.yml | 2 + app/src/main/resources/application-dev.yml | 10 +++ app/src/main/resources/application-local.yml | 12 ++- .../tinyengine/it/config/OpenAIConfig.java | 6 ++ .../app/impl/v1/AiChatV1ServiceImpl.java | 78 ++++++++++++++++- .../app/impl/v1/AiChatV1ServiceImplTest.java | 86 +++++++++++++++++++ 6 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java diff --git a/app/src/main/resources/application-alpha.yml b/app/src/main/resources/application-alpha.yml index ed635829..93f5caa6 100644 --- a/app/src/main/resources/application-alpha.yml +++ b/app/src/main/resources/application-alpha.yml @@ -89,3 +89,5 @@ logging: cors: allowed-origins: "*" +ai: + allowed-hosts: diff --git a/app/src/main/resources/application-dev.yml b/app/src/main/resources/application-dev.yml index 2b402a56..dade6bdd 100644 --- a/app/src/main/resources/application-dev.yml +++ b/app/src/main/resources/application-dev.yml @@ -91,3 +91,13 @@ logging: cors: allowed-origins: "*" +ai: + allowed-hosts: + - api.openai.com + - api.deepseek.com + - dashscope.aliyuncs.com + - aip.baidubce.com + - api.moonshot.cn + - localhost + - 127.0.0.1 + diff --git a/app/src/main/resources/application-local.yml b/app/src/main/resources/application-local.yml index 6002f7e1..1d28066b 100644 --- a/app/src/main/resources/application-local.yml +++ b/app/src/main/resources/application-local.yml @@ -46,4 +46,14 @@ cors: allowed-methods: "GET,POST,PUT,DELETE,OPTIONS" allowed-headers: "Accept,Referer,User-Agent,x-lowcode-mode,x-lowcode-org,Content-Type,Authorization" exposed-headers: "Authorization" - allow-credentials: true \ No newline at end of file + allow-credentials: true + +ai: + allowed-hosts: + - api.openai.com + - api.deepseek.com + - dashscope.aliyuncs.com + - aip.baidubce.com + - api.moonshot.cn + - localhost + - 127.0.0.1 \ No newline at end of file diff --git a/base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java b/base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java index 0de5a4fd..57e1244d 100644 --- a/base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java +++ b/base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java @@ -13,8 +13,12 @@ package com.tinyengine.it.config; import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; +import java.util.ArrayList; +import java.util.List; + /** * The type Open AI config. * @@ -22,9 +26,11 @@ */ @Data @Configuration +@ConfigurationProperties(prefix = "ai") public class OpenAIConfig { private String apiKey = "your-api-key"; private String baseUrl = "https://api.deepseek.com/chat/completions"; private String defaultModel = "deepseek-chat"; private int timeoutSeconds = 300; + private List allowedHosts = new ArrayList<>(); } diff --git a/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java b/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java index 2d5d88a9..c32e91c0 100644 --- a/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java +++ b/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java @@ -26,14 +26,19 @@ import java.io.IOException; import java.io.InputStream; +import java.net.InetAddress; import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Set; /** * The type AiChat v1 service. @@ -43,10 +48,16 @@ @Slf4j @Service public class AiChatV1ServiceImpl implements AiChatV1Service { - private final OpenAIConfig config = new OpenAIConfig(); - private HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds())) - .build(); + private final OpenAIConfig config; + private final HttpClient httpClient; + + public AiChatV1ServiceImpl(OpenAIConfig config) { + this.config = config; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds())) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + } /** * chatCompletion. @@ -65,6 +76,9 @@ public Object chatCompletion(ChatRequest request) throws Exception { // 规范化URL处理 String normalizedUrl = normalizeApiUrl(baseUrl); + // 对最终请求 URL 做安全校验(在 normalize 之后,确保校验的是真正发出的地址) + validateFinalUrl(normalizedUrl); + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() .uri(URI.create(normalizedUrl)) .header("Content-Type", "application/json") @@ -233,6 +247,62 @@ private StreamingResponseBody processStreamResponse(HttpRequest.Builder requestB }; } + private static final Set LOOPBACK_HOSTS = Set.of("localhost", "127.0.0.1", "[::1]"); + + void validateFinalUrl(String finalUrl) { + URI uri; + try { + uri = new URI(finalUrl); + } catch (URISyntaxException e) { + throw new ServiceException("400", "Invalid baseUrl format"); + } + + String host = uri.getHost(); + if (host == null || host.isEmpty()) { + throw new ServiceException("400", "Invalid baseUrl: missing host"); + } + + boolean isLoopback = LOOPBACK_HOSTS.contains(host.toLowerCase()); + + List allowedHosts = config.getAllowedHosts(); + + if (allowedHosts != null && !allowedHosts.isEmpty()) { + boolean matched = allowedHosts.stream() + .anyMatch(allowed -> allowed.equalsIgnoreCase(host)); + if (!matched) { + throw new ServiceException("400", + "Host not allowed: " + host + ". Allowed hosts: " + allowedHosts); + } + + if (isLoopback) { + return; + } + + enforceHttpsAndIpCheck(uri, host); + } else { + enforceHttpsAndIpCheck(uri, host); + } + } + + void enforceHttpsAndIpCheck(URI uri, String host) { + String scheme = uri.getScheme(); + if (scheme == null || !"https".equalsIgnoreCase(scheme)) { + throw new ServiceException("400", "Only HTTPS protocol is allowed for custom baseUrl"); + } + + try { + InetAddress address = InetAddress.getByName(host); + if (address.isLoopbackAddress() + || address.isSiteLocalAddress() + || address.isLinkLocalAddress() + || address.isAnyLocalAddress()) { + throw new ServiceException("400", "Internal network addresses are not allowed"); + } + } catch (UnknownHostException e) { + throw new ServiceException("400", "Unable to resolve host: " + host); + } + } + private String getApiKey(String encryptApiKey) throws Exception { String sm4Key = System.getenv("SM4KEY"); diff --git a/base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java b/base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java new file mode 100644 index 00000000..d5e61a2d --- /dev/null +++ b/base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java @@ -0,0 +1,86 @@ +package com.tinyengine.it.service.app.impl.v1; + +import com.tinyengine.it.common.exception.ServiceException; +import com.tinyengine.it.config.OpenAIConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class AiChatV1ServiceImplTest { + private AiChatV1ServiceImpl service; + private OpenAIConfig config; + + @BeforeEach + void setUp() { + config = new OpenAIConfig(); + service = new AiChatV1ServiceImpl(config); + } + + // === 无白名单模式(严格校验)=== + + @Test + void shouldAllowPublicHttpsUrl() { + assertDoesNotThrow(() -> + service.validateFinalUrl("https://api.openai.com/v1/chat/completions")); + } + + @ParameterizedTest + @ValueSource(strings = { + "http://127.0.0.1:8080/v1/chat/completions", + "http://localhost:11434/v1/chat/completions", + "https://192.168.1.1/v1/chat/completions", + "https://10.0.0.1/v1/chat/completions", + "https://169.254.169.254/latest/meta-data/" + }) + void shouldRejectInternalAddresses(String url) { + assertThrows(ServiceException.class, () -> + service.validateFinalUrl(url)); + } + + @Test + void shouldRejectHttpForPublicHost() { + assertThrows(ServiceException.class, () -> + service.validateFinalUrl("http://api.openai.com/v1/chat/completions")); + } + + @Test + void shouldRejectInvalidUrl() { + assertThrows(ServiceException.class, () -> + service.validateFinalUrl("not-a-valid-url")); + } + + // === 白名单模式 === + + @Test + void shouldAllowWhitelistedHost() { + config.setAllowedHosts(List.of("api.deepseek.com", "api.openai.com")); + assertDoesNotThrow(() -> + service.validateFinalUrl("https://api.deepseek.com/v1/chat/completions")); + } + + @Test + void shouldRejectNonWhitelistedHost() { + config.setAllowedHosts(List.of("api.deepseek.com")); + assertThrows(ServiceException.class, () -> + service.validateFinalUrl("https://api.openai.com/v1/chat/completions")); + } + + @Test + void shouldAllowWhitelistedLocalhostWithHttp() { + config.setAllowedHosts(List.of("localhost", "127.0.0.1")); + assertDoesNotThrow(() -> + service.validateFinalUrl("http://localhost:11434/v1/chat/completions")); + } + + @Test + void shouldRejectHttpForWhitelistedExternalHost() { + config.setAllowedHosts(List.of("api.deepseek.com")); + assertThrows(ServiceException.class, () -> + service.validateFinalUrl("http://api.deepseek.com/v1/chat/completions")); + } +} From e9fd2a0c60e281613627519da205eeedf1f1d559 Mon Sep 17 00:00:00 2001 From: hexqi Date: Tue, 28 Apr 2026 22:30:04 +0800 Subject: [PATCH 2/3] fix(ai): update allowed hosts and stricter validation for AI services --- app/src/main/resources/application-alpha.yml | 14 ++ app/src/main/resources/application-dev.yml | 12 +- app/src/main/resources/application-local.yml | 13 +- .../app/impl/v1/AiChatV1ServiceImpl.java | 135 +++++++++++++----- .../app/impl/v1/AiChatV1ServiceImplTest.java | 56 +++++++- 5 files changed, 191 insertions(+), 39 deletions(-) diff --git a/app/src/main/resources/application-alpha.yml b/app/src/main/resources/application-alpha.yml index 93f5caa6..03297bf9 100644 --- a/app/src/main/resources/application-alpha.yml +++ b/app/src/main/resources/application-alpha.yml @@ -91,3 +91,17 @@ cors: ai: allowed-hosts: + - api.openai.com + - api.deepseek.com + - dashscope.aliyuncs.com + - qianfan.baidubce.com + - api.hunyuan.cloud.tencent.com + - ark.cn-beijing.volces.com + - open.bigmodel.cn + - api.moonshot.cn + - api.01.ai + - api.minimax.chat + - spark-api.cn-huabei-1.xf-yun.com + - api.sensenova.cn + - api.baichuan-ai.com + - api.tiangong.cn diff --git a/app/src/main/resources/application-dev.yml b/app/src/main/resources/application-dev.yml index dade6bdd..a704d684 100644 --- a/app/src/main/resources/application-dev.yml +++ b/app/src/main/resources/application-dev.yml @@ -96,8 +96,16 @@ ai: - api.openai.com - api.deepseek.com - dashscope.aliyuncs.com - - aip.baidubce.com + - qianfan.baidubce.com + - api.hunyuan.cloud.tencent.com + - ark.cn-beijing.volces.com + - open.bigmodel.cn - api.moonshot.cn + - api.01.ai + - api.minimax.chat + - spark-api.cn-huabei-1.xf-yun.com + - api.sensenova.cn + - api.baichuan-ai.com + - api.tiangong.cn - localhost - 127.0.0.1 - diff --git a/app/src/main/resources/application-local.yml b/app/src/main/resources/application-local.yml index 1d28066b..6a6f30b6 100644 --- a/app/src/main/resources/application-local.yml +++ b/app/src/main/resources/application-local.yml @@ -53,7 +53,16 @@ ai: - api.openai.com - api.deepseek.com - dashscope.aliyuncs.com - - aip.baidubce.com + - qianfan.baidubce.com + - api.hunyuan.cloud.tencent.com + - ark.cn-beijing.volces.com + - open.bigmodel.cn - api.moonshot.cn + - api.01.ai + - api.minimax.chat + - spark-api.cn-huabei-1.xf-yun.com + - api.sensenova.cn + - api.baichuan-ai.com + - api.tiangong.cn - localhost - - 127.0.0.1 \ No newline at end of file + - 127.0.0.1 diff --git a/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java b/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java index c32e91c0..2cdeb08a 100644 --- a/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java +++ b/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java @@ -24,20 +24,23 @@ import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; -import java.io.IOException; -import java.io.InputStream; -import java.net.InetAddress; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.UnknownHostException; +import java.io.IOException; +import java.io.InputStream; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.Inet6Address; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.time.Duration; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -247,7 +250,7 @@ private StreamingResponseBody processStreamResponse(HttpRequest.Builder requestB }; } - private static final Set LOOPBACK_HOSTS = Set.of("localhost", "127.0.0.1", "[::1]"); + private static final Set LOOPBACK_HOSTS = Set.of("localhost", "127.0.0.1", "::1", "[::1]"); void validateFinalUrl(String finalUrl) { URI uri; @@ -284,27 +287,95 @@ void validateFinalUrl(String finalUrl) { } } - void enforceHttpsAndIpCheck(URI uri, String host) { - String scheme = uri.getScheme(); - if (scheme == null || !"https".equalsIgnoreCase(scheme)) { - throw new ServiceException("400", "Only HTTPS protocol is allowed for custom baseUrl"); - } - - try { - InetAddress address = InetAddress.getByName(host); - if (address.isLoopbackAddress() - || address.isSiteLocalAddress() - || address.isLinkLocalAddress() - || address.isAnyLocalAddress()) { - throw new ServiceException("400", "Internal network addresses are not allowed"); - } - } catch (UnknownHostException e) { - throw new ServiceException("400", "Unable to resolve host: " + host); - } - } - - private String getApiKey(String encryptApiKey) throws Exception { - String sm4Key = System.getenv("SM4KEY"); + void enforceHttpsAndIpCheck(URI uri, String host) { + String scheme = uri.getScheme(); + if (scheme == null || !"https".equalsIgnoreCase(scheme)) { + throw new ServiceException("400", "Only HTTPS protocol is allowed for custom baseUrl"); + } + + try { + InetAddress[] addresses = resolveHostAddresses(host); + boolean hasBlockedAddress = Arrays.stream(addresses).anyMatch(this::isBlockedAddress); + if (hasBlockedAddress) { + throw new ServiceException("400", "Internal network addresses are not allowed"); + } + } catch (UnknownHostException e) { + throw new ServiceException("400", "Unable to resolve host: " + host); + } + } + + InetAddress[] resolveHostAddresses(String host) throws UnknownHostException { + return InetAddress.getAllByName(host); + } + + boolean isBlockedAddress(InetAddress address) { + if (address.isLoopbackAddress() + || address.isSiteLocalAddress() + || address.isLinkLocalAddress() + || address.isAnyLocalAddress() + || address.isMulticastAddress()) { + return true; + } + + if (address instanceof Inet4Address) { + return isBlockedIpv4((Inet4Address) address); + } + if (address instanceof Inet6Address) { + return isBlockedIpv6((Inet6Address) address); + } + return false; + } + + private boolean isBlockedIpv4(Inet4Address address) { + byte[] octets = address.getAddress(); + int first = octets[0] & 0xFF; + int second = octets[1] & 0xFF; + int third = octets[2] & 0xFF; + + if (first == 0) { + return true; + } + if (first == 100 && second >= 64 && second <= 127) { + return true; + } + if (first == 192 && second == 0 && third == 0) { + return true; + } + if (first == 192 && second == 0 && third == 2) { + return true; + } + if (first == 198 && (second == 18 || second == 19)) { + return true; + } + if (first == 198 && second == 51 && third == 100) { + return true; + } + if (first == 203 && second == 0 && third == 113) { + return true; + } + return first >= 240; + } + + private boolean isBlockedIpv6(Inet6Address address) { + byte[] octets = address.getAddress(); + int first = octets[0] & 0xFF; + int second = octets[1] & 0xFF; + + if ((first & 0xFE) == 0xFC) { + return true; + } + if (first == 0x20 && second == 0x01) { + int third = octets[2] & 0xFF; + int fourth = octets[3] & 0xFF; + if (third == 0x0D && fourth == 0xB8) { + return true; + } + } + return first == 0xFF; + } + + private String getApiKey(String encryptApiKey) throws Exception { + String sm4Key = System.getenv("SM4KEY"); if (encryptApiKey.startsWith("EKEY_")) { String encryptBase64ApiKey = encryptApiKey.substring(5); diff --git a/base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java b/base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java index d5e61a2d..ea4fc247 100644 --- a/base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java +++ b/base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java @@ -7,18 +7,25 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.HashMap; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; class AiChatV1ServiceImplTest { - private AiChatV1ServiceImpl service; + private TestAiChatV1ServiceImpl service; private OpenAIConfig config; @BeforeEach - void setUp() { + void setUp() throws Exception { config = new OpenAIConfig(); - service = new AiChatV1ServiceImpl(config); + service = new TestAiChatV1ServiceImpl(config); + service.stubHost("api.openai.com", "8.8.8.8"); + service.stubHost("api.deepseek.com", "1.1.1.1"); + service.stubHost("example.com", "93.184.216.34"); } // === 无白名单模式(严格校验)=== @@ -83,4 +90,47 @@ void shouldRejectHttpForWhitelistedExternalHost() { assertThrows(ServiceException.class, () -> service.validateFinalUrl("http://api.deepseek.com/v1/chat/completions")); } + + @Test + void shouldRejectCarrierGradeNatAddress() { + assertThrows(ServiceException.class, () -> + service.validateFinalUrl("https://100.64.0.1/v1/chat/completions")); + } + + @Test + void shouldRejectBenchmarkingAddress() { + assertThrows(ServiceException.class, () -> + service.validateFinalUrl("https://198.18.0.1/v1/chat/completions")); + } + + @Test + void shouldRejectIpv6UniqueLocalAddress() { + assertThrows(ServiceException.class, () -> + service.validateFinalUrl("https://[fc00::1]/v1/chat/completions")); + } + + private static final class TestAiChatV1ServiceImpl extends AiChatV1ServiceImpl { + private final Map resolvedHosts = new HashMap<>(); + + private TestAiChatV1ServiceImpl(OpenAIConfig config) { + super(config); + } + + private void stubHost(String host, String... addresses) throws UnknownHostException { + InetAddress[] resolved = new InetAddress[addresses.length]; + for (int i = 0; i < addresses.length; i++) { + resolved[i] = InetAddress.getByName(addresses[i]); + } + resolvedHosts.put(host, resolved); + } + + @Override + InetAddress[] resolveHostAddresses(String host) throws UnknownHostException { + InetAddress[] resolved = resolvedHosts.get(host); + if (resolved != null) { + return resolved; + } + return super.resolveHostAddresses(host); + } + } } From 616853a8106f7afec1ab4611bf47f860a0e295aa Mon Sep 17 00:00:00 2001 From: hexqi Date: Tue, 28 Apr 2026 22:53:58 +0800 Subject: [PATCH 3/3] fix(ai): enforce host restrictions for AI services --- app/src/main/resources/application-alpha.yml | 1 + app/src/main/resources/application-dev.yml | 1 + app/src/main/resources/application-local.yml | 1 + .../tinyengine/it/config/OpenAIConfig.java | 15 +++--- .../app/impl/v1/AiChatV1ServiceImpl.java | 47 ++++++++++--------- .../app/impl/v1/AiChatV1ServiceImplTest.java | 18 ++++++- 6 files changed, 53 insertions(+), 30 deletions(-) diff --git a/app/src/main/resources/application-alpha.yml b/app/src/main/resources/application-alpha.yml index 03297bf9..3ef129e3 100644 --- a/app/src/main/resources/application-alpha.yml +++ b/app/src/main/resources/application-alpha.yml @@ -90,6 +90,7 @@ cors: allowed-origins: "*" ai: + allow-any-host: false allowed-hosts: - api.openai.com - api.deepseek.com diff --git a/app/src/main/resources/application-dev.yml b/app/src/main/resources/application-dev.yml index a704d684..14d9a581 100644 --- a/app/src/main/resources/application-dev.yml +++ b/app/src/main/resources/application-dev.yml @@ -92,6 +92,7 @@ cors: allowed-origins: "*" ai: + allow-any-host: false allowed-hosts: - api.openai.com - api.deepseek.com diff --git a/app/src/main/resources/application-local.yml b/app/src/main/resources/application-local.yml index 6a6f30b6..d13f78cf 100644 --- a/app/src/main/resources/application-local.yml +++ b/app/src/main/resources/application-local.yml @@ -49,6 +49,7 @@ cors: allow-credentials: true ai: + allow-any-host: false allowed-hosts: - api.openai.com - api.deepseek.com diff --git a/base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java b/base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java index 57e1244d..38bd39a0 100644 --- a/base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java +++ b/base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java @@ -27,10 +27,11 @@ @Data @Configuration @ConfigurationProperties(prefix = "ai") -public class OpenAIConfig { - private String apiKey = "your-api-key"; - private String baseUrl = "https://api.deepseek.com/chat/completions"; - private String defaultModel = "deepseek-chat"; - private int timeoutSeconds = 300; - private List allowedHosts = new ArrayList<>(); -} +public class OpenAIConfig { + private String apiKey = "your-api-key"; + private String baseUrl = "https://api.deepseek.com/chat/completions"; + private String defaultModel = "deepseek-chat"; + private int timeoutSeconds = 300; + private List allowedHosts = new ArrayList<>(); + private boolean allowAnyHost = false; +} diff --git a/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java b/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java index 2cdeb08a..eb585b92 100644 --- a/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java +++ b/base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java @@ -265,27 +265,32 @@ void validateFinalUrl(String finalUrl) { throw new ServiceException("400", "Invalid baseUrl: missing host"); } - boolean isLoopback = LOOPBACK_HOSTS.contains(host.toLowerCase()); - - List allowedHosts = config.getAllowedHosts(); - - if (allowedHosts != null && !allowedHosts.isEmpty()) { - boolean matched = allowedHosts.stream() - .anyMatch(allowed -> allowed.equalsIgnoreCase(host)); - if (!matched) { - throw new ServiceException("400", - "Host not allowed: " + host + ". Allowed hosts: " + allowedHosts); - } - - if (isLoopback) { - return; - } - - enforceHttpsAndIpCheck(uri, host); - } else { - enforceHttpsAndIpCheck(uri, host); - } - } + boolean isLoopback = LOOPBACK_HOSTS.contains(host.toLowerCase()); + + List allowedHosts = config.getAllowedHosts(); + + if (allowedHosts == null || allowedHosts.isEmpty()) { + if (!config.isAllowAnyHost()) { + throw new ServiceException("500", "No AI allowed hosts configured"); + } + + enforceHttpsAndIpCheck(uri, host); + return; + } + + boolean matched = allowedHosts.stream() + .anyMatch(allowed -> allowed.equalsIgnoreCase(host)); + if (!matched) { + throw new ServiceException("400", + "Host not allowed: " + host + ". Allowed hosts: " + allowedHosts); + } + + if (isLoopback) { + return; + } + + enforceHttpsAndIpCheck(uri, host); + } void enforceHttpsAndIpCheck(URI uri, String host) { String scheme = uri.getScheme(); diff --git a/base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java b/base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java index ea4fc247..e6791da3 100644 --- a/base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java +++ b/base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java @@ -28,10 +28,18 @@ void setUp() throws Exception { service.stubHost("example.com", "93.184.216.34"); } - // === 无白名单模式(严格校验)=== + // === 无白名单模式(默认 fail-closed)=== @Test - void shouldAllowPublicHttpsUrl() { + void shouldRejectWhenNoAllowedHostsConfigured() { + ServiceException exception = assertThrows(ServiceException.class, () -> + service.validateFinalUrl("https://api.openai.com/v1/chat/completions")); + assertEquals("No AI allowed hosts configured", exception.getMessage()); + } + + @Test + void shouldAllowPublicHttpsUrlWhenAllowAnyHostEnabled() { + config.setAllowAnyHost(true); assertDoesNotThrow(() -> service.validateFinalUrl("https://api.openai.com/v1/chat/completions")); } @@ -45,18 +53,21 @@ void shouldAllowPublicHttpsUrl() { "https://169.254.169.254/latest/meta-data/" }) void shouldRejectInternalAddresses(String url) { + config.setAllowAnyHost(true); assertThrows(ServiceException.class, () -> service.validateFinalUrl(url)); } @Test void shouldRejectHttpForPublicHost() { + config.setAllowAnyHost(true); assertThrows(ServiceException.class, () -> service.validateFinalUrl("http://api.openai.com/v1/chat/completions")); } @Test void shouldRejectInvalidUrl() { + config.setAllowAnyHost(true); assertThrows(ServiceException.class, () -> service.validateFinalUrl("not-a-valid-url")); } @@ -93,18 +104,21 @@ void shouldRejectHttpForWhitelistedExternalHost() { @Test void shouldRejectCarrierGradeNatAddress() { + config.setAllowAnyHost(true); assertThrows(ServiceException.class, () -> service.validateFinalUrl("https://100.64.0.1/v1/chat/completions")); } @Test void shouldRejectBenchmarkingAddress() { + config.setAllowAnyHost(true); assertThrows(ServiceException.class, () -> service.validateFinalUrl("https://198.18.0.1/v1/chat/completions")); } @Test void shouldRejectIpv6UniqueLocalAddress() { + config.setAllowAnyHost(true); assertThrows(ServiceException.class, () -> service.validateFinalUrl("https://[fc00::1]/v1/chat/completions")); }