Skip to content

Commit 4a10aa5

Browse files
authored
fix(ai): prevent SSRF in AI chat endpoints (#306)
* fix: prevent SSRF in AI chat endpoints via URL validation * fix(ai): update allowed hosts and stricter validation for AI services * fix(ai): enforce host restrictions for AI services
1 parent d02daa9 commit 4a10aa5

6 files changed

Lines changed: 378 additions & 19 deletions

File tree

app/src/main/resources/application-alpha.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,20 @@ logging:
8989
cors:
9090
allowed-origins: "*"
9191

92+
ai:
93+
allow-any-host: false
94+
allowed-hosts:
95+
- api.openai.com
96+
- api.deepseek.com
97+
- dashscope.aliyuncs.com
98+
- qianfan.baidubce.com
99+
- api.hunyuan.cloud.tencent.com
100+
- ark.cn-beijing.volces.com
101+
- open.bigmodel.cn
102+
- api.moonshot.cn
103+
- api.01.ai
104+
- api.minimax.chat
105+
- spark-api.cn-huabei-1.xf-yun.com
106+
- api.sensenova.cn
107+
- api.baichuan-ai.com
108+
- api.tiangong.cn

app/src/main/resources/application-dev.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,22 @@ logging:
9191
cors:
9292
allowed-origins: "*"
9393

94+
ai:
95+
allow-any-host: false
96+
allowed-hosts:
97+
- api.openai.com
98+
- api.deepseek.com
99+
- dashscope.aliyuncs.com
100+
- qianfan.baidubce.com
101+
- api.hunyuan.cloud.tencent.com
102+
- ark.cn-beijing.volces.com
103+
- open.bigmodel.cn
104+
- api.moonshot.cn
105+
- api.01.ai
106+
- api.minimax.chat
107+
- spark-api.cn-huabei-1.xf-yun.com
108+
- api.sensenova.cn
109+
- api.baichuan-ai.com
110+
- api.tiangong.cn
111+
- localhost
112+
- 127.0.0.1

app/src/main/resources/application-local.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,24 @@ cors:
4646
allowed-methods: "GET,POST,PUT,DELETE,OPTIONS"
4747
allowed-headers: "Accept,Referer,User-Agent,x-lowcode-mode,x-lowcode-org,Content-Type,Authorization"
4848
exposed-headers: "Authorization"
49-
allow-credentials: true
49+
allow-credentials: true
50+
51+
ai:
52+
allow-any-host: false
53+
allowed-hosts:
54+
- api.openai.com
55+
- api.deepseek.com
56+
- dashscope.aliyuncs.com
57+
- qianfan.baidubce.com
58+
- api.hunyuan.cloud.tencent.com
59+
- ark.cn-beijing.volces.com
60+
- open.bigmodel.cn
61+
- api.moonshot.cn
62+
- api.01.ai
63+
- api.minimax.chat
64+
- spark-api.cn-huabei-1.xf-yun.com
65+
- api.sensenova.cn
66+
- api.baichuan-ai.com
67+
- api.tiangong.cn
68+
- localhost
69+
- 127.0.0.1

base/src/main/java/com/tinyengine/it/config/OpenAIConfig.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,25 @@
1313
package com.tinyengine.it.config;
1414

1515
import lombok.Data;
16+
import org.springframework.boot.context.properties.ConfigurationProperties;
1617
import org.springframework.context.annotation.Configuration;
1718

19+
import java.util.ArrayList;
20+
import java.util.List;
21+
1822
/**
1923
* The type Open AI config.
2024
*
2125
* @since 2025-08-06
2226
*/
2327
@Data
2428
@Configuration
25-
public class OpenAIConfig {
26-
private String apiKey = "your-api-key";
27-
private String baseUrl = "https://api.deepseek.com/chat/completions";
28-
private String defaultModel = "deepseek-chat";
29-
private int timeoutSeconds = 300;
30-
}
29+
@ConfigurationProperties(prefix = "ai")
30+
public class OpenAIConfig {
31+
private String apiKey = "your-api-key";
32+
private String baseUrl = "https://api.deepseek.com/chat/completions";
33+
private String defaultModel = "deepseek-chat";
34+
private int timeoutSeconds = 300;
35+
private List<String> allowedHosts = new ArrayList<>();
36+
private boolean allowAnyHost = false;
37+
}

base/src/main/java/com/tinyengine/it/service/app/impl/v1/AiChatV1ServiceImpl.java

Lines changed: 158 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,24 @@
2424
import org.springframework.stereotype.Service;
2525
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
2626

27-
import java.io.IOException;
28-
import java.io.InputStream;
29-
import java.net.URI;
27+
import java.io.IOException;
28+
import java.io.InputStream;
29+
import java.net.Inet4Address;
30+
import java.net.InetAddress;
31+
import java.net.Inet6Address;
32+
import java.net.URI;
33+
import java.net.URISyntaxException;
34+
import java.net.UnknownHostException;
3035
import java.net.http.HttpClient;
3136
import java.net.http.HttpRequest;
3237
import java.net.http.HttpResponse;
3338
import java.nio.charset.StandardCharsets;
34-
import java.time.Duration;
35-
import java.util.HashMap;
36-
import java.util.Map;
39+
import java.time.Duration;
40+
import java.util.Arrays;
41+
import java.util.HashMap;
42+
import java.util.List;
43+
import java.util.Map;
44+
import java.util.Set;
3745

3846
/**
3947
* The type AiChat v1 service.
@@ -43,10 +51,16 @@
4351
@Slf4j
4452
@Service
4553
public class AiChatV1ServiceImpl implements AiChatV1Service {
46-
private final OpenAIConfig config = new OpenAIConfig();
47-
private HttpClient httpClient = HttpClient.newBuilder()
48-
.connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds()))
49-
.build();
54+
private final OpenAIConfig config;
55+
private final HttpClient httpClient;
56+
57+
public AiChatV1ServiceImpl(OpenAIConfig config) {
58+
this.config = config;
59+
this.httpClient = HttpClient.newBuilder()
60+
.connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds()))
61+
.followRedirects(HttpClient.Redirect.NEVER)
62+
.build();
63+
}
5064

5165
/**
5266
* chatCompletion.
@@ -65,6 +79,9 @@ public Object chatCompletion(ChatRequest request) throws Exception {
6579
// 规范化URL处理
6680
String normalizedUrl = normalizeApiUrl(baseUrl);
6781

82+
// 对最终请求 URL 做安全校验(在 normalize 之后,确保校验的是真正发出的地址)
83+
validateFinalUrl(normalizedUrl);
84+
6885
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
6986
.uri(URI.create(normalizedUrl))
7087
.header("Content-Type", "application/json")
@@ -233,8 +250,137 @@ private StreamingResponseBody processStreamResponse(HttpRequest.Builder requestB
233250
};
234251
}
235252

236-
private String getApiKey(String encryptApiKey) throws Exception {
237-
String sm4Key = System.getenv("SM4KEY");
253+
private static final Set<String> LOOPBACK_HOSTS = Set.of("localhost", "127.0.0.1", "::1", "[::1]");
254+
255+
void validateFinalUrl(String finalUrl) {
256+
URI uri;
257+
try {
258+
uri = new URI(finalUrl);
259+
} catch (URISyntaxException e) {
260+
throw new ServiceException("400", "Invalid baseUrl format");
261+
}
262+
263+
String host = uri.getHost();
264+
if (host == null || host.isEmpty()) {
265+
throw new ServiceException("400", "Invalid baseUrl: missing host");
266+
}
267+
268+
boolean isLoopback = LOOPBACK_HOSTS.contains(host.toLowerCase());
269+
270+
List<String> allowedHosts = config.getAllowedHosts();
271+
272+
if (allowedHosts == null || allowedHosts.isEmpty()) {
273+
if (!config.isAllowAnyHost()) {
274+
throw new ServiceException("500", "No AI allowed hosts configured");
275+
}
276+
277+
enforceHttpsAndIpCheck(uri, host);
278+
return;
279+
}
280+
281+
boolean matched = allowedHosts.stream()
282+
.anyMatch(allowed -> allowed.equalsIgnoreCase(host));
283+
if (!matched) {
284+
throw new ServiceException("400",
285+
"Host not allowed: " + host + ". Allowed hosts: " + allowedHosts);
286+
}
287+
288+
if (isLoopback) {
289+
return;
290+
}
291+
292+
enforceHttpsAndIpCheck(uri, host);
293+
}
294+
295+
void enforceHttpsAndIpCheck(URI uri, String host) {
296+
String scheme = uri.getScheme();
297+
if (scheme == null || !"https".equalsIgnoreCase(scheme)) {
298+
throw new ServiceException("400", "Only HTTPS protocol is allowed for custom baseUrl");
299+
}
300+
301+
try {
302+
InetAddress[] addresses = resolveHostAddresses(host);
303+
boolean hasBlockedAddress = Arrays.stream(addresses).anyMatch(this::isBlockedAddress);
304+
if (hasBlockedAddress) {
305+
throw new ServiceException("400", "Internal network addresses are not allowed");
306+
}
307+
} catch (UnknownHostException e) {
308+
throw new ServiceException("400", "Unable to resolve host: " + host);
309+
}
310+
}
311+
312+
InetAddress[] resolveHostAddresses(String host) throws UnknownHostException {
313+
return InetAddress.getAllByName(host);
314+
}
315+
316+
boolean isBlockedAddress(InetAddress address) {
317+
if (address.isLoopbackAddress()
318+
|| address.isSiteLocalAddress()
319+
|| address.isLinkLocalAddress()
320+
|| address.isAnyLocalAddress()
321+
|| address.isMulticastAddress()) {
322+
return true;
323+
}
324+
325+
if (address instanceof Inet4Address) {
326+
return isBlockedIpv4((Inet4Address) address);
327+
}
328+
if (address instanceof Inet6Address) {
329+
return isBlockedIpv6((Inet6Address) address);
330+
}
331+
return false;
332+
}
333+
334+
private boolean isBlockedIpv4(Inet4Address address) {
335+
byte[] octets = address.getAddress();
336+
int first = octets[0] & 0xFF;
337+
int second = octets[1] & 0xFF;
338+
int third = octets[2] & 0xFF;
339+
340+
if (first == 0) {
341+
return true;
342+
}
343+
if (first == 100 && second >= 64 && second <= 127) {
344+
return true;
345+
}
346+
if (first == 192 && second == 0 && third == 0) {
347+
return true;
348+
}
349+
if (first == 192 && second == 0 && third == 2) {
350+
return true;
351+
}
352+
if (first == 198 && (second == 18 || second == 19)) {
353+
return true;
354+
}
355+
if (first == 198 && second == 51 && third == 100) {
356+
return true;
357+
}
358+
if (first == 203 && second == 0 && third == 113) {
359+
return true;
360+
}
361+
return first >= 240;
362+
}
363+
364+
private boolean isBlockedIpv6(Inet6Address address) {
365+
byte[] octets = address.getAddress();
366+
int first = octets[0] & 0xFF;
367+
int second = octets[1] & 0xFF;
368+
369+
if ((first & 0xFE) == 0xFC) {
370+
return true;
371+
}
372+
if (first == 0x20 && second == 0x01) {
373+
int third = octets[2] & 0xFF;
374+
int fourth = octets[3] & 0xFF;
375+
if (third == 0x0D && fourth == 0xB8) {
376+
return true;
377+
}
378+
}
379+
return first == 0xFF;
380+
}
381+
382+
private String getApiKey(String encryptApiKey) throws Exception {
383+
String sm4Key = System.getenv("SM4KEY");
238384

239385
if (encryptApiKey.startsWith("EKEY_")) {
240386
String encryptBase64ApiKey = encryptApiKey.substring(5);

0 commit comments

Comments
 (0)