🤖
AI审核中

别让付费大模型成为后端漏洞:SpringBoot多模型路由、鉴权与计费安全实践

  Java   35分钟   194浏览   1评论

接入大模型 API 并不复杂:前端提交模型 ID,后端找到对应的接口地址和 API Key,再发起一次 HTTP 请求即可。

但当系统同时接入免费模型、会员模型和按量计费模型后,真正困难的部分就不再是“如何调用模型”,而是如何建立可靠的权限边界与成本边界:

  • 普通用户能否绕过前端限制,直接调用付费模型?
  • 攻击者能否伪造 userId,冒充已授权用户?
  • 分享标题、搜索词生成等隐式功能,会不会意外使用付费模型?
  • Redis 或网络发生故障时,系统应该继续放行,还是拒绝请求?
  • 请求超时后立即重试,会不会造成重复计费?
  • 付费模型的 API Key,是否可能被发送到错误的服务器?

本文以一个 Spring Boot 2.7 多模型聊天系统为例,介绍如何将模型路由、身份验证、模型授权、额度控制和 HTTP 出站调用收敛到同一个安全边界中。

为了便于说明,文中使用“用户 ID 为 1 的账户可以使用 Kimi”作为示例。生产环境中,更适合使用独立的权限表、授权服务或 entitlement 机制,而不是长期依赖硬编码用户 ID。

核心原则只有一句:

前端只能表达调用意图,不能决定最终权限、接口地址、API Key 和计费策略。


一、不要让业务代码直接调用模型供应商

一个大模型系统中,真正会调用模型的功能往往不止聊天接口,还可能包括:

  • 普通聊天;
  • AI 率检测;
  • 降 AI 率改写;
  • 联网搜索词生成;
  • 分享标题生成;
  • 后台摘要任务;
  • 内容分类与审核辅助。

如果这些功能各自读取 API Key、拼接接口地址并发送 HTTP 请求,安全逻辑就会迅速散落到不同服务中。

聊天接口可能检查了用户权限,标题生成任务却忘记检查;某个调用实现了限流,另一个内部服务却绕过了限流。更严重的是,只要任意一个业务服务可以直接读取付费 API Key,统一访问层就不再是真正的安全边界。

因此,所有离开服务器的 AI 请求都应经过统一的出站访问层:

Controller / Service
        ↓
AiProviderAccessService
        ├─ 认证主体校验
        ├─ 模型白名单校验
        ├─ 调用用途校验
        ├─ Provider 与 Endpoint 校验
        ├─ 额度与并发租约控制
        └─ 请求生命周期管理
        ↓
ProviderEndpoint
        ↓
模型供应商 API

只有 AiProviderAccessService 或其底层组件可以:

  • 读取供应商 API Key;
  • 创建带认证头的 HTTP 请求;
  • 选择模型供应商地址;
  • 获取和释放计费租约;
  • 控制超时、重试和重定向行为。

业务代码只负责提交“调用什么能力”的请求,不直接接触凭据和底层 HTTP 客户端。

这相当于为整个系统设置了一道“AI 出站防火墙”。


二、模型配置不能只是一个字符串

在多模型系统中,不同模型之间的差异远不止模型名称。

有些模型支持图片,有些支持工具调用;有些返回独立的推理字段,有些把推理过程混在正文中;不同供应商还可能使用不同的参数格式、Token 字段和响应结构。

因此,模型应该被视为一个由服务端管理的策略对象,而不是任意字符串。

例如:

ai:
  api:
    models:
      - id: "kimi-k2.6"
        supportsReasoning: true
        supportsImage: true
        supportsTools: true
        reasoningOutput: "native"

      - id: "openai/gpt-oss-120b"
        supportsReasoning: true
        supportsImage: false
        supportsTools: true
        reasoningOutput: "content-carried"
        free: true

    default-model: "openai/gpt-oss-120b"
    default-free-model: "openai/gpt-oss-120b"

后端通过 @ConfigurationProperties 将配置绑定为模型对象,并统一判断模型能力:

public boolean supportsImage(String modelId) {
    ModelConfig model = findModelConfig(modelId);
    return model != null
            && Boolean.TRUE.equals(model.getSupportsImage());
}

public boolean isFreeModel(String modelId) {
    ModelConfig model = findModelConfig(modelId);
    return model != null
            && Boolean.TRUE.equals(model.getFree());
}

这样做有两个直接好处。

第一,业务代码不再通过模型名称猜测能力。

第二,模型是否免费、是否支持图片、是否允许工具调用,都由服务端配置决定,前端无法自行声明。

对于付费属性,建议默认采用保守语义:除非服务端明确标记为免费,否则不要因为字段缺失而自动将模型视为免费。

应用启动时还应校验:

  • 模型 ID 是否重复;
  • 默认模型是否真实存在;
  • 免费默认模型是否被标记为免费;
  • 模型别名是否存在冲突;
  • 付费模型是否配置了对应的 Provider 和安全端点。

配置错误最好在启动阶段暴露,而不是等到第一笔付费请求到来时才发现。


三、模型权限必须由服务端重新判断

假设系统规定 Kimi 是付费模型,仅允许用户 ID 为 1 的账户使用。

最危险的实现方式,是相信请求体中的 userId

{
  "userId": 1,
  "model": "kimi-k2.6",
  "message": "你好"
}

攻击者只需要修改请求参数,就可能冒充已授权用户。

正确做法是从服务端认证上下文中取得当前用户,并与业务请求中的用户身份进行比对:

Authentication authentication =
        SecurityContextHolder.getContext().getAuthentication();

if (authentication == null
        || !(authentication.getPrincipal() instanceof User)) {
    throw new BusinessException(401, "用户未登录");
}

User principal = (User) authentication.getPrincipal();

if (!Objects.equals(principal.getId(), userId)) {
    throw new BusinessException(403, "AI请求用户身份不匹配");
}

这样,即使某个调用方错误地向服务层传入了 userId=1,只要当前登录用户不是 1,请求仍然会被拒绝。

更稳妥的设计,是让统一访问层直接从认证上下文取得用户 ID,尽量不让上层业务代码传递可伪造的 userId

Long currentUserId = currentUserService.requireCurrentUserId();

完成身份绑定后,还需要执行模型级授权:

if (isKimiModel(model)
        && userId.longValue() != KIMI_ALLOWED_USER_ID) {
    throw new BusinessException(403, "Kimi仅限授权用户使用");
}

生产环境中,可以将这段判断替换为独立授权服务:

if (isKimiModel(model)
        && !modelEntitlementService.canUse(userId, model)) {
    throw new BusinessException(403, "当前用户无权使用该模型");
}

这里必须坚持一个原则:

管理员、会员或前端展示权限,不能隐式覆盖付费模型的专属授权规则。

“用户是会员”和“用户可以调用某个按量计费模型”是两种不同的权限。将它们混为一谈,很容易因为一次普通的会员逻辑调整,意外打开付费模型入口。

账户禁用、封禁、注销等状态,也应在模型授权之前完成检查。


四、模型白名单必须使用精确匹配

另一类常见漏洞,是通过模糊匹配判断模型类型:

modelId.contains("kimi")

攻击者可以构造类似下面的模型 ID:

proxy/kimi-k2.6-experimental

如果后端只判断字符串中是否包含 kimi,这个未知模型就可能被错误地识别为 Kimi,并被路由到付费接口。

应当使用精确模型 ID 白名单:

private static final String KIMI_MODEL_ID =
        "kimi-k2.6";

private static final String LEGACY_KIMI_MODEL_ID =
        "moonshotai/kimi-k2.6";

public boolean isKimiModel(String modelId) {
    return KIMI_MODEL_ID.equals(modelId)
            || LEGACY_KIMI_MODEL_ID.equals(modelId);
}

历史模型 ID 可以通过明确的别名规则规范化:

public String resolveModelId(String modelId) {
    return isKimiModel(modelId)
            ? KIMI_MODEL_ID
            : modelId;
}

推荐的处理顺序是:

String resolvedModel = resolveModelId(requestedModel);
ModelConfig modelConfig = requireConfiguredModel(resolvedModel);

其中,别名映射本身也必须采用精确匹配。只有服务端明确登记过的历史 ID 才能被规范化,不能根据前缀、后缀或关键词进行猜测。

所有不在服务端模型配置中的 ID,都应直接返回 400 Bad Request,而不是尝试转发给供应商。

这就是典型的 fail-closed:

无法确认模型是否合法时,默认拒绝,而不是继续尝试。


五、付费凭据必须绑定到固定出站地址

即使用户权限检查完全正确,只要付费接口地址可以被任意修改,系统仍然可能泄露 API Key。

例如,配置被错误地修改为:

https://attacker.example/v1/chat/completions

后端随后发送:

Authorization: Bearer <付费API Key>

此时,API Key 会被直接交给攻击者控制的服务器。

因此,付费凭据不能与任意 URL 组合使用。凭据、供应商和端点应被绑定为一个不可随意拆分的 Provider 配置。

以 Kimi 为例,发送请求前至少应验证:

  • Scheme 必须是 https
  • Host 必须精确等于 api.moonshot.cn
  • 端口只能是默认 HTTPS 端口或 443
  • Path 必须精确等于 /v1/chat/completions
  • 不允许包含 User Info;
  • 不允许包含 Query;
  • 不允许包含 Fragment;
  • HTTP 客户端不得自动跟随重定向。

核心判断可以写成:

URI uri = URI.create(endpointUrl);

boolean officialEndpoint =
        "https".equalsIgnoreCase(uri.getScheme())
        && "api.moonshot.cn".equalsIgnoreCase(uri.getHost())
        && (uri.getPort() == -1 || uri.getPort() == 443)
        && "/v1/chat/completions".equals(uri.getRawPath())
        && uri.getUserInfo() == null
        && uri.getRawQuery() == null
        && uri.getRawFragment() == null;

if (!officialEndpoint) {
    throw new BusinessException(503, "仅允许访问官方接口");
}

这里使用 getRawPath(),可以避免编码后的路径被解码成看似合法的结果。

同时应在 HTTP 客户端中显式关闭重定向:

RequestConfig requestConfig =
        RequestConfig.custom()
                .setRedirectsEnabled(false)
                .build();

否则,即使初始地址是官方域名,供应商响应中的 302307 也可能把带有认证信息的后续请求引导到其他地址。

端点校验最好执行两次:

  • 应用启动时校验配置,尽早发现错误;
  • 真正发送请求前再次校验,防止运行时配置变化或绕过。

这道检查不是为了防止用户“选错模型”,而是为了防止服务器主动把付费凭据发送到错误地址。


六、计费额度为什么要使用 Redis Lua

单机环境中,可以使用内存计数器限制请求次数。

但在多实例部署中,每个实例都有自己的本地计数。如果部署三台服务器,每台每分钟允许调用 10 次,那么系统实际可能放行 30 次。

付费模型的额度控制必须使用跨实例共享的存储,并且要保证多个限制之间原子执行。

Redis Lua 适合用来完成以下操作:

  1. 检查当前并发租约数量;
  2. 检查分钟请求数;
  3. 检查每日请求数;
  4. 检查每日预估用量;
  5. 计算本次请求加入后的额度;
  6. 如果任意限制超标,则不写入并直接拒绝;
  7. 如果全部通过,则创建租约、增加计数并设置 TTL。

关键点不是“把多个 Redis 命令放在一起执行”,而是:

检查额度和占用额度必须是同一个原子操作。

否则,两个请求可能同时读取到“剩余一个名额”,随后都成功增加计数。

Redis Key 可以设计为:

ai:kimi:usage:{1}:minute:202607151430
ai:kimi:usage:{1}:day:20260715
ai:kimi:usage:{1}:estimated-units:20260715
ai:kimi:usage:{1}:concurrent

其中 {1} 是 Redis Cluster 的 Hash Tag。

在 Redis Cluster 中,Lua 脚本涉及的多个 Key 必须位于同一个 Hash Slot。使用相同的 {1} 可以保证这些 Key 被路由到同一个槽位,从而在集群环境中原子执行。

系统可以同时限制:

  • 每分钟请求次数;
  • 每日请求次数;
  • 同时进行的请求数量;
  • 每日预估消耗量。

并发租约最好使用唯一随机 Token,而不是只维护一个简单计数。续租和释放时,Lua 脚本必须验证 Token,避免旧请求错误地释放新请求持有的租约。

如果 Redis 不可用,付费模型不能降级为“无限放行”:

catch (Exception e) {
    throw new BusinessException(
            503,
            "计费保护暂时不可用,请稍后重试");
}

对于普通缓存,Redis 故障时允许降级可能是合理的。

但对于计费保护,Redis 故障时继续放行,意味着系统已经失去了请求次数、并发数量和费用上限。

因此,付费请求必须 fail-closed。

需要注意,每日预估用量是一种请求前保护机制,并不等同于供应商最终账单。系统可以在响应中取得实际用量后进行对账或修正,但不能因为后续可以对账,就放弃请求前的额度控制。


七、超时不是失败证明:不确定结果不能立即释放租约

这是计费接口中最容易被忽略的问题之一。

假设后端调用模型供应商时发生读取超时:

客户端 → 本系统 → 模型供应商
                  ↑
           已收到请求并开始处理

本系统没有收到完整响应,并不代表供应商没有收到请求,也不代表供应商没有开始计费。

如果后端在超时后立即释放并发租约,客户端就可以马上重试。此时,供应商可能同时处理第一次请求和第二次请求,形成重复调用甚至重复计费。

因此,请求结束状态至少要分为两类。

1. 已收到确定的终态响应

无论供应商返回:

  • 200
  • 400
  • 429
  • 500

只要响应已经完整接收,本系统就知道该 HTTP 请求已经得到明确结果。

对于流式响应,应在正常读取到流结束标记或完整 EOF 后,才能认定为终态,而不是只收到响应头就立即标记完成。

此时可以释放并发租约。

需要注意,释放并发租约只表示“该请求已经不再处于运行中”,并不表示这次调用一定没有产生费用。对 429500 等响应是否重试,仍应由独立的重试策略决定。

2. 请求结果不确定

以下情况通常属于不确定状态:

  • 请求写出后发生读取超时;
  • 连接在响应过程中被重置;
  • 本地线程被中断;
  • 客户端主动取消请求;
  • 流式响应未正常结束;
  • 网络异常导致无法确认供应商是否已接受请求。

对于这类异常,不应立即释放租约,而应等待 Redis TTL 自动过期,暂时阻止用户立即重试。

如果系统能够明确证明请求尚未离开本机,例如连接建立前就被拒绝,可以将其视为确定的本地失败。但在无法证明时,应采用保守策略。

伪代码如下:

try (ProviderEndpoint endpoint =
             authorizeRequest(...)) {

    ProviderHttpResponse response =
            endpoint.executeJsonPost(body);

    // 必须在完整收到响应后再标记
    endpoint.markTerminalResponseReceived();

    return response;
}

close() 根据请求状态决定是否释放租约:

boolean releaseLease =
        terminalResponseReceived
        && lease != null;

if (releaseLease) {
    lease.close();
}

如果请求状态不确定,close() 只清理本地资源,不主动释放 Redis 租约。

此外,付费 POST 请求应关闭 HTTP 客户端的自动重试。重试必须由业务策略显式触发,不能因为底层网络库认为某次异常“可以重试”,就在调用方不知情的情况下再次发送请求。

对于真实计费接口,宁可让用户多等待一段时间,也不要在无法确认请求状态时制造重复调用。


八、长连接必须使用租约心跳

流式响应可能持续数分钟,复杂任务甚至可能运行更久。

如果并发租约的 TTL 固定为 15 分钟,而某次请求超过 15 分钟,租约就会在请求仍然运行时过期。此时第二个请求可以再次进入,系统的并发限制就会失效。

因此,长请求需要租约续期机制:

  • 请求发送前验证租约仍然有效;
  • 按固定周期续期;
  • 续期时校验唯一租约 Token;
  • 续期失败后终止当前 HTTP 请求;
  • 请求结束后取消心跳任务。

例如:

heartbeatExecutor.scheduleAtFixedRate(
        this::renewLeaseSafely,
        30,
        30,
        TimeUnit.SECONDS);

心跳周期应明显小于租约 TTL,避免一次短暂抖动就导致租约过期。

请求对象还需要注册取消函数:

endpoint.registerAbortAction(httpPost::abort);

当 Redis 续租失败时,系统不能只记录日志后继续请求。否则,HTTP 请求仍然在消耗模型资源,但计费保护已经失效。

因此,续租失败后应立即执行取消操作:

private void renewLeaseSafely() {
    try {
        lease.renew();
    } catch (Exception e) {
        abortCurrentRequest();
    }
}

需要强调的是,取消本地 HTTP 请求并不能保证供应商停止计费,它只能尽快阻止后续内容继续生成。

如果取消发生时请求结果仍然不确定,租约也不应立即释放,仍应等待 TTL 到期。

这样,“Redis fail-closed”才不仅覆盖请求开始前,也覆盖长时间运行中的请求。


九、隐式 AI 功能不能偷偷使用付费模型

用户主动选择付费模型时,费用是可预期的。

但系统中还有大量隐式 AI 调用,例如:

  • 创建分享链接时生成标题;
  • 根据对话生成联网搜索词;
  • 自动生成摘要;
  • 后台生成标签;
  • 为历史记录生成简介。

用户只是点击了“分享”或“保存”,并没有明确选择使用付费模型。

如果这些功能直接沿用当前会话模型,那么一个普通操作也可能触发付费请求。

因此,模型路由不能只考虑“用户是谁”,还要考虑“本次调用用于什么目的”。

可以为调用目的建立明确类型:

public enum AiInvocationPurpose {
    USER_CHAT,
    SHARE_TITLE,
    SEARCH_QUERY,
    BACKGROUND_SUMMARY
}

只有明确的用户聊天请求,才允许根据用户选择进入付费模型路由。辅助功能则统一使用免费或低成本模型。

例如:

String model =
        aiProviderAccessService
                .resolveNonKimiModelForUser(userId);

当前系统可以将普通默认模型、免费默认模型和隐式辅助模型统一设置为:

openai/gpt-oss-120b

即使用户 ID 为 1,分享标题也不会隐式调用 Kimi。

辅助功能还应设置更严格的:

  • 最大输入长度;
  • 最大输出 Token;
  • 连接超时;
  • 连接池等待超时;
  • 响应读取超时。

例如:

endpoint.executeJsonPost(
        requestBody,
        5000,
        5000,
        15000);

当模型异常或超过 15 秒时,系统自动使用本地标题:

对话分享20260715

分享链接仍然可以正常创建。

这体现了一个重要原则:

AI 增强功能可以失败,但核心业务不能跟着失败。


十、按用户变化的模型列表不能被公共缓存

前端模型列表通常会根据用户身份动态变化。

例如:

  • 用户 1 可以看到 Kimi;
  • 其他用户只能看到免费模型;
  • 未登录用户只能看到匿名可用模型。

如果共享代理、CDN 或浏览器错误缓存了用户 1 的响应,其他用户就可能拿到不属于自己的模型列表。

因此,模型列表接口应返回:

Cache-Control: private, no-store, max-age=0
Pragma: no-cache

其中:

  • private 表示响应只能存储在私有缓存中;
  • no-store 表示不应存储该响应;
  • max-age=0 表示响应立即过期。

模型列表响应中也不应包含:

  • API Key;
  • 供应商内部地址;
  • 计费账户信息;
  • 仅供后端使用的路由配置。

需要再次强调:

隐藏前端选项只是用户体验优化,不能替代服务端权限验证。

即使普通用户手动构造 Kimi 请求,统一访问层仍然必须返回 403


十一、将安全规则写成回归测试

计费权限系统不能只依赖人工测试。

它需要通过自动化测试,将权限、路由和请求生命周期规则固化为可执行规范。

至少应覆盖以下场景:

身份与授权
用户 1 + Kimi                              → 允许
用户 2 + Kimi                              → 403
匿名用户 + Kimi                            → 403
内部辅助身份 + Kimi                        → 403
用户 2 伪造 userId=1                       → 403
已禁用的用户 1 + Kimi                      → 403

模型与路由
未知模型 ID                                → 400
包含 kimi 关键词的未知模型                 → 400
合法历史别名                               → 规范化为正式模型 ID
Kimi 指向非官方域名                        → 503
Kimi 地址包含 Query 或 User Info           → 503
官方地址返回重定向                         → 不自动跟随

输入与额度
输入或图片超过限制                         → 413
超过分钟请求额度                           → 拒绝
超过每日请求额度                           → 拒绝
超过每日预估用量                           → 拒绝
超过并发限制                               → 拒绝
Redis 计费保护不可用                       → 503
非 Kimi 请求                               → 不消耗 Kimi 额度

请求生命周期
请求结果不确定                             → 不立即释放租约
完整收到非 2xx 响应                        → 释放并发租约
流式响应未正常结束                         → 不立即释放租约
心跳续租失败                               → 终止当前 HTTP 请求
旧租约 Token                               → 不能释放新租约
付费 POST 网络异常                         → 不自动重试

隐式功能
分享标题生成                               → 使用免费模型
辅助模型超时                               → 使用本地标题
辅助模型失败                               → 不影响分享链接创建

这些测试的价值,不只是发现代码错误。

更重要的是,它们将计费规则和安全边界固化为可执行规范。以后即使有人调整默认模型、修改模型顺序、替换 HTTP 客户端或重构调用链,只要违反这些规则,测试就会立即失败。


十二、总结

接入付费大模型以后,后端需要解决的已经不只是 API 调用问题,而是一套完整的权限、网络和成本控制问题。

一个可靠的多模型架构,至少要关闭四类边界。

1. 身份边界

  • 不信任前端提交的用户 ID;
  • 以服务端认证主体为准;
  • 校验认证用户与业务用户是否一致;
  • 将付费模型权限与普通会员权限分离。

2. 模型边界

  • 模型必须来自服务端白名单;
  • 使用精确 ID 匹配;
  • 只允许明确登记的历史别名;
  • 模型能力和计费属性由服务端配置决定;
  • 未知模型默认拒绝。

3. 网络边界

  • 业务代码不能直接读取付费 API Key;
  • 凭据必须绑定到固定 Provider;
  • 付费请求只允许发送到官方 HTTPS 地址;
  • 禁止 Query、Fragment、User Info 和自动重定向;
  • 禁止底层 HTTP 客户端自动重试付费 POST 请求。

4. 成本边界

  • 使用 Redis Lua 原子控制并发和额度;
  • Redis 异常时对付费请求 fail-closed;
  • 长请求通过心跳维持租约;
  • 续租失败时终止当前请求;
  • 不确定结果下不立即释放租约;
  • 隐式 AI 功能与付费模型隔离;
  • 辅助功能失败时回退到本地逻辑。

大模型系统真正上线后,最昂贵的问题往往不是模型回答得不够好,而是某个看似普通的内部功能,在没有权限边界和成本边界的情况下不断发出请求。

安全的多模型后端,本质上不是一个根据 modelId 执行 switch 的 HTTP 代理,而是一个完整的策略执行点:

它必须同时决定谁可以调用、可以调用什么、凭据可以发往哪里、最多可以花费多少,以及网络结果不确定时应该如何处理。

把模型调用视为一种受保护的出站资源,而不是普通的 HTTP 请求,才是多模型应用真正走向生产环境的开始。

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  1 条评论
召田最帥boy   湖南省长沙市

这下是真吓哭了lei

AI助手
召田最帅boy的小助手
🤖
我是召田最帅boy的小助手
我已经阅读了这篇文章,可以帮您:
理解文章内容 · 解答细节问题 · 分析核心观点