upstream_path_guard.go 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. package service
  2. import (
  3. "fmt"
  4. "strings"
  5. )
  6. // 上游 URL 路径片段护栏。
  7. //
  8. // 网关会把若干客户端可控的字符串(Responses 子路径、Gemini 模型名)拼进上游请求
  9. // 的 URL path。约定:这些字符串只允许"结构惰性"的字符,即拼进去之后不可能改变
  10. // 上游请求的路径结构;不符合的一律拒绝。
  11. //
  12. // 实现上是**闭集允许清单(默认拒绝)**,请勿改成"逐个拒绝已知的坏字符":后者要求
  13. // 穷举,漏一项就失效;默认拒绝则相反,清单外的写法天然被挡住,无需跟着改代码。
  14. //
  15. // 另外注意:到达业务代码的 c.Request.URL.Path 已经是百分号解码后的结果,因此校验
  16. // 必须放在这一层,不能假设上游看到的路径与客户端书写形式一致。
  17. //
  18. // 这里只校验、不改写:把不合规输入自动修正成合规路径,会让上游收到与客户端意图
  19. // 不同的请求,也会掩盖调用方的错误。
  20. const (
  21. // maxUpstreamPathSegmentLen 单个路径片段长度上限。真实的 response id、模型名
  22. // 都远短于此,留足余量只为拒绝异常输入。
  23. maxUpstreamPathSegmentLen = 128
  24. // maxUpstreamPathSegments 后缀允许的片段数上限(如 /{id}/cancel 为 2)。
  25. maxUpstreamPathSegments = 8
  26. )
  27. // isSafeUpstreamPathSegmentByte 是闭集允许清单:只放行 `\w`(即 [A-Za-z0-9_])
  28. // 以及真实取值必需的 `-` 与 `.`。其余字符(含控制字符与非 ASCII)一律拒绝。
  29. func isSafeUpstreamPathSegmentByte(b byte) bool {
  30. switch {
  31. case b >= 'a' && b <= 'z', b >= 'A' && b <= 'Z', b >= '0' && b <= '9':
  32. return true
  33. case b == '_', b == '-', b == '.':
  34. return true
  35. default:
  36. return false
  37. }
  38. }
  39. // isSafeUpstreamPathSegment 判断 segment 能否原样拼进上游 URL 的一个 path 片段。
  40. //
  41. // 允许清单里唯一在路径语义中有特殊含义的字符是 `.`,因此额外要求片段不能只由点
  42. // 组成——各类实现对这种片段的解释并不一致,直接拒绝最省心。
  43. func isSafeUpstreamPathSegment(segment string) bool {
  44. if segment == "" || len(segment) > maxUpstreamPathSegmentLen {
  45. return false
  46. }
  47. dotsOnly := true
  48. for i := 0; i < len(segment); i++ {
  49. if !isSafeUpstreamPathSegmentByte(segment[i]) {
  50. return false
  51. }
  52. if segment[i] != '.' {
  53. dotsOnly = false
  54. }
  55. }
  56. return !dotsOnly
  57. }
  58. // sanitizedUpstreamPathSuffix 校验 "/a/b" 形态的路径后缀。
  59. // ok=false 表示后缀不可转发,调用方必须拒绝请求,而不是降级成空后缀——否则
  60. // /responses/compact 之类的请求语义会被静默改写。空后缀合法,表示"没有子路径"。
  61. func sanitizedUpstreamPathSuffix(raw string) (string, bool) {
  62. suffix := strings.TrimSpace(raw)
  63. if suffix == "" {
  64. return "", true
  65. }
  66. if !strings.HasPrefix(suffix, "/") {
  67. return "", false
  68. }
  69. segments := strings.Split(strings.TrimPrefix(suffix, "/"), "/")
  70. if len(segments) > maxUpstreamPathSegments {
  71. return "", false
  72. }
  73. for _, segment := range segments {
  74. if !isSafeUpstreamPathSegment(segment) {
  75. return "", false
  76. }
  77. }
  78. return suffix, true
  79. }
  80. // validateUpstreamPathSegment 供 URL 构造点使用:不合规的片段直接变成显式错误,
  81. // 不再继续构造与发出上游请求。
  82. func validateUpstreamPathSegment(kind, segment string) error {
  83. if isSafeUpstreamPathSegment(strings.TrimSpace(segment)) {
  84. return nil
  85. }
  86. // 不回显原始输入,避免把它写进日志与错误响应。
  87. return fmt.Errorf("invalid %s for upstream url path", kind)
  88. }