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
15 changes: 15 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,18 @@
`change_me` fallback 让部署者忘配 env 时仍能起服务,但起来的就是弱密码后台
——必须用 `${VAR:?...}` 形式强制部署期校验。
- **历史**:2026-05-07 三方 CR 加固项(端口部分历史已修,本次加测试 + 收紧密码默认值)。

## INV-006 · 付费 LLM 端点必须每用户限流

- **保护点**:`OpenAiStreamController#streamResponses`(`/openai/responses/stream`)
与 `OpenAiStreamRateLimiter`
- **测试**:
- `OpenAiStreamRateLimiterTests#underLimitPassesAndOverLimitGets429`
- `OpenAiStreamRateLimiterTests#usersAreIsolated`
- `OpenAiStreamRateLimiterTests#windowExpiryResetsTheCounter`
- **为什么**:该端点烧付费 LLM 额度。`@SaCheckLogin` 只挡未登录;登录用户可
绕过 Next.js 层的 Upstash 限流直接 curl 后端(Caddy 裸透传不过滤路径),
无限流时单用户即可刷爆账单(#297 估算 ~$5/小时)。限流必须落在 Java 层
本身,不能只依赖前端网关。上限经 `openai.stream.requests-per-minute`
配置(默认 10/分钟/用户),调大需说明场景。
- **历史**:2026-04-16 由 #297 报告;2026-07-18 加限流 + 本不变量。
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.involutionhell.backend.openai.controller;

import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.stp.StpUtil;
import com.involutionhell.backend.openai.dto.OpenAiStreamRequest;
import com.involutionhell.backend.openai.service.OpenAiStreamRateLimiter;
import com.involutionhell.backend.openai.service.OpenAiStreamService;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
Expand All @@ -17,9 +19,12 @@
public class OpenAiStreamController {

private final OpenAiStreamService openAiStreamService;
private final OpenAiStreamRateLimiter rateLimiter;

public OpenAiStreamController(OpenAiStreamService openAiStreamService) {
public OpenAiStreamController(
OpenAiStreamService openAiStreamService, OpenAiStreamRateLimiter rateLimiter) {
this.openAiStreamService = openAiStreamService;
this.rateLimiter = rateLimiter;
}

/**
Expand All @@ -30,6 +35,9 @@ public OpenAiStreamController(OpenAiStreamService openAiStreamService) {
@SaCheckLogin
@PostMapping(path = "/responses/stream", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.TEXT_PLAIN_VALUE)
public ResponseEntity<StreamingResponseBody> streamResponses(@Valid @RequestBody OpenAiStreamRequest request) {
// INV-006:付费 LLM 端点必须限流。@SaCheckLogin 只挡未登录,
// 登录用户绕过 Next.js 直 curl 后端仍会被这里的每用户窗口拦住(#297)
rateLimiter.checkOrThrow(StpUtil.getLoginIdAsLong());
/*
* ============================
* 🗑️ 被废弃的旧版方法声明留痕:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.involutionhell.backend.openai.service;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Ticker;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;

/**
* /openai/responses/stream 的每用户限流(INV-006)。
*
* <p>背景(issue #297):该端点烧的是付费 LLM 额度,此前只有 @SaCheckLogin
* 没有限流——登录用户可绕过 Next.js 层的 Upstash 限流直接 curl 后端刷额度。
* Caddy 是裸透传,所以限流必须落在 Java 层本身。
*
* <p>实现:Caffeine 固定窗口计数(每用户每分钟 N 次,写后 1 分钟过期)。
* 进程内存级即可——后端单实例部署;将来横向扩容时换 Redis 计数即可,
* 本类接口不变。窗口边界的突发(最多 2N/瞬间)对"防刷额度"场景无关紧要,
* 不值得为此上滑动窗口。
*/
@Component
public class OpenAiStreamRateLimiter {

private final int requestsPerMinute;
private final Cache<Long, AtomicInteger> windows;

// 有两个构造器时 Spring 需要显式指定注入入口,否则 context 起不来
@Autowired
public OpenAiStreamRateLimiter(
@Value("${openai.stream.requests-per-minute:10}") int requestsPerMinute) {
this(requestsPerMinute, Ticker.systemTicker());
}

/** 测试用:可注入假时钟推进窗口。 */
OpenAiStreamRateLimiter(int requestsPerMinute, Ticker ticker) {
this.requestsPerMinute = requestsPerMinute;
this.windows = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(1))
.ticker(ticker)
// 上限 = 防御性兜底:即使被恶意刷出海量 userId 也不至于撑爆内存
.maximumSize(100_000)
.build();
}

/**
* 记一次调用;超限抛 429。
*
* @throws ResponseStatusException TOO_MANY_REQUESTS 当分钟窗口内已达上限
*/
public void checkOrThrow(long userId) {
AtomicInteger counter = windows.get(userId, id -> new AtomicInteger());
if (counter.incrementAndGet() > requestsPerMinute) {
throw new ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS,
"chat rate limit exceeded: " + requestsPerMinute + " requests/minute");
}
}
}
3 changes: 3 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ community.alert.webhook-url=${COMMUNITY_ALERT_WEBHOOK_URL:}
openai.api-key=${OPENAI_API_KEY:}
openai.api-url=${OPENAI_API_URL:https://api.openai.com/v1}
openai.model=${OPENAI_MODEL:gpt-4.1}
# 流式对话每用户限流(INV-006,#297):付费 LLM 端点必须限流。
# @SaCheckLogin 只挡未登录;登录用户绕过 Next.js 直 curl 时由这里兜底
openai.stream.requests-per-minute=${OPENAI_STREAM_RPM:10}

# ==========================================
# Sa-Token ??
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.involutionhell.backend.openai.service;

import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;

import com.github.benmanes.caffeine.cache.Ticker;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;

/**
* INV-006 回归测试:/openai/responses/stream 的每用户限流。
*
* <p>背景见 issue #297——该端点烧付费 LLM 额度,登录用户可绕过前端限流
* 直 curl 后端。此处守住:窗口内超限必 429、不同用户独立、窗口过期后恢复。
*/
class OpenAiStreamRateLimiterTests {

/** 可手动推进的假时钟。 */
private static final class FakeTicker implements Ticker {
private final AtomicLong nanos = new AtomicLong();

@Override
public long read() {
return nanos.get();
}

void advanceSeconds(long seconds) {
nanos.addAndGet(TimeUnit.SECONDS.toNanos(seconds));
}
}

@Test
void underLimitPassesAndOverLimitGets429() {
OpenAiStreamRateLimiter limiter = new OpenAiStreamRateLimiter(3, new FakeTicker());

assertThatCode(() -> {
limiter.checkOrThrow(1L);
limiter.checkOrThrow(1L);
limiter.checkOrThrow(1L);
}).doesNotThrowAnyException();

assertThatThrownBy(() -> limiter.checkOrThrow(1L))
.isInstanceOfSatisfying(ResponseStatusException.class,
e -> assertThat(e.getStatusCode()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS));
}

@Test
void usersAreIsolated() {
OpenAiStreamRateLimiter limiter = new OpenAiStreamRateLimiter(1, new FakeTicker());

limiter.checkOrThrow(1L);
// 用户 1 已满,用户 2 不受影响
assertThatCode(() -> limiter.checkOrThrow(2L)).doesNotThrowAnyException();
assertThatThrownBy(() -> limiter.checkOrThrow(1L))
.isInstanceOf(ResponseStatusException.class);
}

@Test
void windowExpiryResetsTheCounter() {
FakeTicker ticker = new FakeTicker();
OpenAiStreamRateLimiter limiter = new OpenAiStreamRateLimiter(1, ticker);

limiter.checkOrThrow(1L);
assertThatThrownBy(() -> limiter.checkOrThrow(1L))
.isInstanceOf(ResponseStatusException.class);

// 窗口(1 分钟)过期后计数清零
ticker.advanceSeconds(61);
assertThatCode(() -> limiter.checkOrThrow(1L)).doesNotThrowAnyException();
}
}