diff --git a/SECURITY.md b/SECURITY.md index 1f9dba2..46c1368 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 加限流 + 本不变量。 diff --git a/src/main/java/com/involutionhell/backend/openai/controller/OpenAiStreamController.java b/src/main/java/com/involutionhell/backend/openai/controller/OpenAiStreamController.java index f26cc9d..2a16c1b 100644 --- a/src/main/java/com/involutionhell/backend/openai/controller/OpenAiStreamController.java +++ b/src/main/java/com/involutionhell/backend/openai/controller/OpenAiStreamController.java @@ -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; @@ -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; } /** @@ -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 streamResponses(@Valid @RequestBody OpenAiStreamRequest request) { + // INV-006:付费 LLM 端点必须限流。@SaCheckLogin 只挡未登录, + // 登录用户绕过 Next.js 直 curl 后端仍会被这里的每用户窗口拦住(#297) + rateLimiter.checkOrThrow(StpUtil.getLoginIdAsLong()); /* * ============================ * 🗑️ 被废弃的旧版方法声明留痕: diff --git a/src/main/java/com/involutionhell/backend/openai/service/OpenAiStreamRateLimiter.java b/src/main/java/com/involutionhell/backend/openai/service/OpenAiStreamRateLimiter.java new file mode 100644 index 0000000..67866dc --- /dev/null +++ b/src/main/java/com/involutionhell/backend/openai/service/OpenAiStreamRateLimiter.java @@ -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)。 + * + *

背景(issue #297):该端点烧的是付费 LLM 额度,此前只有 @SaCheckLogin + * 没有限流——登录用户可绕过 Next.js 层的 Upstash 限流直接 curl 后端刷额度。 + * Caddy 是裸透传,所以限流必须落在 Java 层本身。 + * + *

实现:Caffeine 固定窗口计数(每用户每分钟 N 次,写后 1 分钟过期)。 + * 进程内存级即可——后端单实例部署;将来横向扩容时换 Redis 计数即可, + * 本类接口不变。窗口边界的突发(最多 2N/瞬间)对"防刷额度"场景无关紧要, + * 不值得为此上滑动窗口。 + */ +@Component +public class OpenAiStreamRateLimiter { + + private final int requestsPerMinute; + private final Cache 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"); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c636205..fe85aeb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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 ?? diff --git a/src/test/java/com/involutionhell/backend/openai/service/OpenAiStreamRateLimiterTests.java b/src/test/java/com/involutionhell/backend/openai/service/OpenAiStreamRateLimiterTests.java new file mode 100644 index 0000000..c6b5fa2 --- /dev/null +++ b/src/test/java/com/involutionhell/backend/openai/service/OpenAiStreamRateLimiterTests.java @@ -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 的每用户限流。 + * + *

背景见 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(); + } +}