Skip to content
Merged
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
17 changes: 17 additions & 0 deletions app/src/main/resources/application-alpha.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 19 additions & 0 deletions app/src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
hexqi marked this conversation as resolved.
22 changes: 21 additions & 1 deletion app/src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
19 changes: 13 additions & 6 deletions base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,25 @@
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.
*
* @since 2025-08-06
*/
@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<String> allowedHosts = new ArrayList<>();
private boolean allowAnyHost = false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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")
Expand Down Expand Up @@ -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<String> 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<String> 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);
Comment thread
hexqi marked this conversation as resolved.
}

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);
Expand Down
Loading
Loading