AI 应用的 CORS 和安全头——前端直接调 AI 接口的安全配置
AI 应用的 CORS 和安全头——前端直接调 AI 接口的安全配置
有段时间我在给一个创业团队做 Code Review,他们的 AI 产品前端直接调后端 AI 接口。我翻了一下前端代码,直接在浏览器控制台就能看到这段:
const response = await fetch('https://api.xxx.com/ai/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': 'sk-xxxxxxxxxxxxxxxx' // API Key 直接写在前端!
},
body: JSON.stringify({ message: userInput })
});我当时脸都绿了。这个 API Key 一旦被人拿到,就可以无限制地调用他们的 LLM API,费用直接爆炸。更糟糕的是,他们的接口没有任何速率限制,有人写个脚本循环调用,一天就能把他们的账单刷到几千美元。
这不是个别情况。很多 AI 应用在快速迭代的过程中,安全设计都会欠账。今天这篇文章,把 AI 接口安全的几个核心问题系统讲一遍。
前端直接调 AI 接口的安全风险
先把风险梳理清楚,再谈解决方案:
核心原则:API Key 绝对不能出现在前端
这是最基本的原则,没有例外。
正确的架构是:
前端 → 你的后端 → LLM API
↑
API Key 只在这里前端调用你的后端接口,你的后端用 API Key 调用 LLM 提供商。前端只能见到你签发的用户 Token,永远看不到 LLM 的 API Key。
CORS 配置
什么是 CORS 以及为什么重要
CORS(跨源资源共享)是浏览器的安全机制,防止恶意网站用你的用户的身份调用你的 API。
一个错误的 CORS 配置:
// 错误:允许所有来源
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("*"); // 这行让安全策略形同虚设
config.addAllowedMethod("*");
config.addAllowedHeader("*");
// ...
}Access-Control-Allow-Origin: * 意味着任何网站都可以通过用户的浏览器调用你的 API。如果你的 API 是用用户 Token 认证的(Cookie 或 Authorization 头),攻击者可以在他的恶意网站上让用户的浏览器帮他调用你的接口。
正确的 CORS 配置
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${ai.security.allowed-origins}")
private List<String> allowedOrigins;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf
// AI 接口通常是 API 接口,可以禁用 CSRF(但要用 Token 认证代替)
.ignoringRequestMatchers("/api/**")
)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态
)
.addFilterBefore(new JwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
// 添加安全响应头
.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentTypeOptions(contentType -> {})
.xssProtection(xss -> xss.enable())
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000) // 1年
)
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'; object-src 'none'")
)
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 只允许明确列出的来源,不用通配符
configuration.setAllowedOrigins(allowedOrigins);
// 例如:["https://app.yourproduct.com", "https://admin.yourproduct.com"]
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of(
"Authorization",
"Content-Type",
"X-Request-Id",
"X-User-Id"
));
// 允许携带 Cookie(如果使用 Cookie 认证)
configuration.setAllowCredentials(true);
// 预检请求缓存时间(减少 OPTIONS 请求)
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
}配置文件:
# application.yml
ai:
security:
allowed-origins:
- "https://app.yourproduct.com"
- "https://admin.yourproduct.com"
# 开发环境可以加 localhost
# - "http://localhost:3000" # 只在 dev profile 中加Rate Limiting(速率限制)
为什么 AI 接口的限流比普通接口更重要
每次 AI 调用都有成本,没有限流就相当于把你的信用卡放出来让人刷。
多层限流策略
基于 Redis 的多维度限流实现
@Component
public class AIRateLimiter {
private static final Logger log = LoggerFactory.getLogger(AIRateLimiter.class);
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 用户维度:每分钟最多20次
private static final int USER_REQUESTS_PER_MINUTE = 20;
// 用户维度:每天最多500次
private static final int USER_REQUESTS_PER_DAY = 500;
// 会话维度:每秒最多1次(防双击)
private static final int SESSION_REQUESTS_PER_SECOND = 1;
// 用户每日 Token 上限(约束成本)
private static final int USER_DAILY_TOKEN_LIMIT = 100_000;
/**
* 全面的速率检查(使用 Lua 脚本保证原子性)
*/
public RateLimitResult checkRateLimit(String userId, String sessionId) {
// 检查用户每分钟限制
if (!checkUserMinuteLimit(userId)) {
return RateLimitResult.limited("请求过于频繁,请1分钟后再试",
getResetTimeSeconds("user_minute:" + userId, 60));
}
// 检查用户每天限制
if (!checkUserDayLimit(userId)) {
return RateLimitResult.limited("今日使用次数已达上限",
getSecondsUntilMidnight());
}
// 检查会话防双击
if (!checkSessionDebounce(sessionId)) {
return RateLimitResult.limited("请稍等,上一条消息正在处理中", 1);
}
return RateLimitResult.allowed();
}
private boolean checkUserMinuteLimit(String userId) {
String key = "rate:user_minute:" + userId;
return executeCountCheck(key, USER_REQUESTS_PER_MINUTE, 60);
}
private boolean checkUserDayLimit(String userId) {
String key = "rate:user_day:" + userId + ":" +
LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
return executeCountCheck(key, USER_REQUESTS_PER_DAY, 86400);
}
private boolean checkSessionDebounce(String sessionId) {
String key = "rate:session:" + sessionId;
return executeCountCheck(key, SESSION_REQUESTS_PER_SECOND, 1);
}
private boolean executeCountCheck(String key, int limit, int expireSeconds) {
String luaScript = """
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
return 0
end
return 1
""";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
List.of(key),
String.valueOf(limit),
String.valueOf(expireSeconds)
);
return result != null && result == 1;
}
/**
* 检查并消耗 Token 配额
*/
public boolean checkTokenQuota(String userId, int estimatedTokens) {
String key = "rate:user_tokens:" + userId + ":" +
LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
String luaScript = """
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local limit = tonumber(ARGV[1])
local consume = tonumber(ARGV[2])
if current + consume > limit then
return 0
end
redis.call('INCRBY', KEYS[1], consume)
redis.call('EXPIRE', KEYS[1], 86400)
return 1
""";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
List.of(key),
String.valueOf(USER_DAILY_TOKEN_LIMIT),
String.valueOf(estimatedTokens)
);
return result != null && result == 1;
}
private long getResetTimeSeconds(String key, int defaultExpire) {
Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
return ttl != null && ttl > 0 ? ttl : defaultExpire;
}
private long getSecondsUntilMidnight() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime midnight = now.toLocalDate().plusDays(1).atStartOfDay();
return ChronoUnit.SECONDS.between(now, midnight);
}
@Data
@Builder
public static class RateLimitResult {
private boolean allowed;
private String message;
private long retryAfterSeconds;
public static RateLimitResult allowed() {
return RateLimitResult.builder().allowed(true).build();
}
public static RateLimitResult limited(String message, long retryAfterSeconds) {
return RateLimitResult.builder()
.allowed(false)
.message(message)
.retryAfterSeconds(retryAfterSeconds)
.build();
}
}
}代码:安全 AI 接口的 Spring Security 配置
JWT 认证过滤器
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = extractToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
String userId = jwtTokenProvider.getUserId(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(userId);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}完整的 AI 接口 Controller
@RestController
@RequestMapping("/api/chat")
@Slf4j
public class SecureAIChatController {
@Autowired
private ChatModel chatModel;
@Autowired
private AIRateLimiter rateLimiter;
@Autowired
private PromptInjectionDetector injectionDetector;
@PostMapping
public ResponseEntity<?> chat(
@RequestBody @Valid ChatRequest request,
@AuthenticationPrincipal UserDetails userDetails,
HttpServletRequest httpRequest) {
String userId = userDetails.getUsername();
String clientIp = getClientIp(httpRequest);
// 1. 速率检查
AIRateLimiter.RateLimitResult rateLimit =
rateLimiter.checkRateLimit(userId, request.getSessionId());
if (!rateLimit.isAllowed()) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header("Retry-After", String.valueOf(rateLimit.getRetryAfterSeconds()))
.body(Map.of("error", rateLimit.getMessage()));
}
// 2. Token 配额检查(粗略估算)
int estimatedTokens = request.getMessage().length() / 3 + 500;
if (!rateLimiter.checkTokenQuota(userId, estimatedTokens)) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(Map.of("error", "今日Token配额已用完,明天再来吧"));
}
// 3. Prompt 注入检测
if (injectionDetector.detect(request.getMessage())) {
log.warn("检测到可能的 Prompt 注入攻击: userId={}, ip={}", userId, clientIp);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "输入内容包含不允许的格式"));
}
// 4. 输入内容长度限制
if (request.getMessage().length() > 10000) {
return ResponseEntity.badRequest()
.body(Map.of("error", "输入内容过长,最大支持10000字符"));
}
try {
// 5. 调用 AI(此时用的是服务端的 API Key,前端看不到)
String response = chatModel.call(request.getMessage());
// 6. 输出内容过滤(防止 AI 输出敏感信息)
String filteredResponse = filterSensitiveOutput(response);
return ResponseEntity.ok(Map.of(
"content", filteredResponse,
"sessionId", request.getSessionId()
));
} catch (Exception e) {
log.error("AI调用失败: userId={}", userId, e);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("error", "AI服务暂时不可用,请稍后重试"));
}
}
private String getClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isEmpty()) {
return xff.split(",")[0].trim();
}
return request.getRemoteAddr();
}
private String filterSensitiveOutput(String content) {
// 基础过滤:移除可能的内部信息泄露
// 例如:过滤掉看起来像 API Key 的字符串
return content.replaceAll("sk-[a-zA-Z0-9]{20,}", "[REDACTED]");
}
}Prompt 注入检测
@Component
public class PromptInjectionDetector {
// 常见的 Prompt 注入模式
private static final List<Pattern> INJECTION_PATTERNS = List.of(
Pattern.compile("ignore (all |previous |above )?instructions", Pattern.CASE_INSENSITIVE),
Pattern.compile("system prompt", Pattern.CASE_INSENSITIVE),
Pattern.compile("\\[INST\\]|<\\|system\\|>|<\\|user\\|>", Pattern.CASE_INSENSITIVE),
Pattern.compile("you are now|act as|pretend (to be|you are)", Pattern.CASE_INSENSITIVE),
Pattern.compile("DAN|jailbreak|bypass", Pattern.CASE_INSENSITIVE)
);
/**
* 检测 Prompt 注入攻击
* 注意:这只是基础检测,不能依赖它作为唯一防线
* 真正的防线是合理设计 System Prompt 和使用 LLM 的安全功能
*/
public boolean detect(String userInput) {
if (userInput == null) return false;
for (Pattern pattern : INJECTION_PATTERNS) {
if (pattern.matcher(userInput).find()) {
return true;
}
}
return false;
}
}安全头配置
@Component
public class SecurityHeadersFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 防止 MIME 类型嗅探
response.setHeader("X-Content-Type-Options", "nosniff");
// 防止点击劫持
response.setHeader("X-Frame-Options", "DENY");
// XSS 保护(现代浏览器已内置,但旧浏览器需要)
response.setHeader("X-XSS-Protection", "1; mode=block");
// HSTS(强制 HTTPS)
response.setHeader("Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload");
// 对 AI 接口响应,移除 Server 头(不暴露服务器信息)
response.setHeader("Server", "");
// 限制 Referrer 信息(防止 AI 对话内容通过 Referrer 泄露)
response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
// 权限策略(禁用不需要的浏览器功能)
response.setHeader("Permissions-Policy",
"camera=(), microphone=(), geolocation=()");
filterChain.doFilter(request, response);
}
}总结
AI 接口的安全配置核心要点:
API Key 永远在服务端:前端只能见到你签发的用户 Token,绝不能见到 LLM 提供商的 API Key。
CORS 配置要精确:
allowedOrigins写明确的域名,不用*;不需要认证的公开接口才能用*。限流是成本保护的第一道防线:用户维度 + 会话维度 + Token 配额三层限流,防止恶意调用把账单刷爆。
Prompt 注入检测要有,但不能只依赖它:基于规则的注入检测很容易绕过,真正的防线是 System Prompt 的合理设计,以及 LLM 提供商的安全功能。
输出也要过滤:AI 输出的内容可能包含你不想暴露的信息,上线前要考虑输出过滤策略。
