Files
sub2api/backend/internal/service/upstream_path_guard.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

96 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"fmt"
"strings"
)
// 上游 URL 路径片段护栏。
//
// 网关会把若干客户端可控的字符串(Responses 子路径、Gemini 模型名)拼进上游请求
// 的 URL path。约定:这些字符串只允许"结构惰性"的字符,即拼进去之后不可能改变
// 上游请求的路径结构;不符合的一律拒绝。
//
// 实现上是**闭集允许清单(默认拒绝)**,请勿改成"逐个拒绝已知的坏字符":后者要求
// 穷举,漏一项就失效;默认拒绝则相反,清单外的写法天然被挡住,无需跟着改代码。
//
// 另外注意:到达业务代码的 c.Request.URL.Path 已经是百分号解码后的结果,因此校验
// 必须放在这一层,不能假设上游看到的路径与客户端书写形式一致。
//
// 这里只校验、不改写:把不合规输入自动修正成合规路径,会让上游收到与客户端意图
// 不同的请求,也会掩盖调用方的错误。
const (
// maxUpstreamPathSegmentLen 单个路径片段长度上限。真实的 response id、模型名
// 都远短于此,留足余量只为拒绝异常输入。
maxUpstreamPathSegmentLen = 128
// maxUpstreamPathSegments 后缀允许的片段数上限(如 /{id}/cancel 为 2)。
maxUpstreamPathSegments = 8
)
// isSafeUpstreamPathSegmentByte 是闭集允许清单:只放行 `\w`(即 [A-Za-z0-9_]
// 以及真实取值必需的 `-` 与 `.`。其余字符(含控制字符与非 ASCII)一律拒绝。
func isSafeUpstreamPathSegmentByte(b byte) bool {
switch {
case b >= 'a' && b <= 'z', b >= 'A' && b <= 'Z', b >= '0' && b <= '9':
return true
case b == '_', b == '-', b == '.':
return true
default:
return false
}
}
// isSafeUpstreamPathSegment 判断 segment 能否原样拼进上游 URL 的一个 path 片段。
//
// 允许清单里唯一在路径语义中有特殊含义的字符是 `.`,因此额外要求片段不能只由点
// 组成——各类实现对这种片段的解释并不一致,直接拒绝最省心。
func isSafeUpstreamPathSegment(segment string) bool {
if segment == "" || len(segment) > maxUpstreamPathSegmentLen {
return false
}
dotsOnly := true
for i := 0; i < len(segment); i++ {
if !isSafeUpstreamPathSegmentByte(segment[i]) {
return false
}
if segment[i] != '.' {
dotsOnly = false
}
}
return !dotsOnly
}
// sanitizedUpstreamPathSuffix 校验 "/a/b" 形态的路径后缀。
// ok=false 表示后缀不可转发,调用方必须拒绝请求,而不是降级成空后缀——否则
// /responses/compact 之类的请求语义会被静默改写。空后缀合法,表示"没有子路径"。
func sanitizedUpstreamPathSuffix(raw string) (string, bool) {
suffix := strings.TrimSpace(raw)
if suffix == "" {
return "", true
}
if !strings.HasPrefix(suffix, "/") {
return "", false
}
segments := strings.Split(strings.TrimPrefix(suffix, "/"), "/")
if len(segments) > maxUpstreamPathSegments {
return "", false
}
for _, segment := range segments {
if !isSafeUpstreamPathSegment(segment) {
return "", false
}
}
return suffix, true
}
// validateUpstreamPathSegment 供 URL 构造点使用:不合规的片段直接变成显式错误,
// 不再继续构造与发出上游请求。
func validateUpstreamPathSegment(kind, segment string) error {
if isSafeUpstreamPathSegment(strings.TrimSpace(segment)) {
return nil
}
// 不回显原始输入,避免把它写进日志与错误响应。
return fmt.Errorf("invalid %s for upstream url path", kind)
}