diff --git a/app/src/main/resources/application-alpha.yml b/app/src/main/resources/application-alpha.yml index ed635829..3ef129e3 100644 --- a/app/src/main/resources/application-alpha.yml +++ b/app/src/main/resources/application-alpha.yml @@ -89,3 +89,20 @@ logging: cors: allowed-origins: "*" +ai: + allow-any-host: false + 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 2b402a56..14d9a581 100644 --- a/app/src/main/resources/application-dev.yml +++ b/app/src/main/resources/application-dev.yml @@ -91,3 +91,22 @@ logging: cors: allowed-origins: "*" +ai: + allow-any-host: false + 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 + - 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..d13f78cf 100644 --- a/app/src/main/resources/application-local.yml +++ b/app/src/main/resources/application-local.yml @@ -46,4 +46,24 @@ 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: + allow-any-host: false + 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 + - localhost + - 127.0.0.1 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..38bd39a0 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,12 @@ */ @Data @Configuration -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; -} +@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<>(); + 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 2d5d88a9..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 @@ -24,16 +24,24 @@ 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.URI; +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.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; /** * The type AiChat v1 service. @@ -43,10 +51,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 +79,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,8 +250,137 @@ private StreamingResponseBody processStreamResponse(HttpRequest.Builder requestB }; } - private String getApiKey(String encryptApiKey) throws Exception { - String sm4Key = System.getenv("SM4KEY"); + private static final Set LOOPBACK_HOSTS = Set.of("localhost", "127.0.0.1", "::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()) { + 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(); + 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 new file mode 100644 index 00000000..e6791da3 --- /dev/null +++ b/base/src/test/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImplTest.java @@ -0,0 +1,150 @@ +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.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 TestAiChatV1ServiceImpl service; + private OpenAIConfig config; + + @BeforeEach + void setUp() throws Exception { + config = new OpenAIConfig(); + 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"); + } + + // === 无白名单模式(默认 fail-closed)=== + + @Test + 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")); + } + + @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) { + 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")); + } + + // === 白名单模式 === + + @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")); + } + + @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")); + } + + 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); + } + } +}