upstream_path_guard_test.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. package service
  2. import (
  3. "net/http"
  4. "net/http/httptest"
  5. "testing"
  6. "github.com/gin-gonic/gin"
  7. "github.com/stretchr/testify/require"
  8. )
  9. func TestSanitizedUpstreamPathSuffixRejectsNonConformingSegments(t *testing.T) {
  10. // 到达业务代码的 URL.Path 已是百分号解码后的结果,因此用例按解码后的形态书写。
  11. rejected := []string{
  12. "/..",
  13. "/../..",
  14. "/../../x/y",
  15. "/./compact",
  16. "/compact/..",
  17. `/..\..\x`,
  18. `/compact\..`,
  19. "/?a=b",
  20. "/compact?a=b",
  21. "/compact#frag",
  22. "/compact%2f..",
  23. "/100%",
  24. "//double",
  25. "/compact//detail",
  26. "/compact/",
  27. "/ compact",
  28. "/compact\x00",
  29. "/compact\nX-Injected: 1",
  30. "/模型",
  31. "compact",
  32. "/a:b",
  33. "/a;b",
  34. "/a,b",
  35. "/a=b",
  36. "/a&b",
  37. // 允许清单是闭集:`\w` + `-` + `.` 以外的字符一律拒绝,
  38. // 不依赖任何"已知坏字符"清单。
  39. "/a~b",
  40. "/a@b",
  41. "/a+b",
  42. "/a|b",
  43. "/a*b",
  44. "/a$b",
  45. "/a(b)",
  46. "/a'b",
  47. "/a\"b",
  48. "/a<b",
  49. "/a\tb",
  50. "/a b",
  51. "/a∕b", // DIVISION SLASH
  52. "/a/b", // FULLWIDTH SOLIDUS
  53. // 只由点组成的片段一律拒绝(各实现对其解释不一致)。
  54. "/...",
  55. "/....",
  56. "/compact/...",
  57. }
  58. for _, suffix := range rejected {
  59. t.Run("reject_"+suffix, func(t *testing.T) {
  60. got, ok := sanitizedUpstreamPathSuffix(suffix)
  61. require.False(t, ok, "suffix %q must be rejected", suffix)
  62. require.Empty(t, got)
  63. })
  64. }
  65. accepted := map[string]string{
  66. "": "",
  67. "/compact": "/compact",
  68. "/compact/detail": "/compact/detail",
  69. "/resp_68f0a1b2c3d4/cancel": "/resp_68f0a1b2c3d4/cancel",
  70. "/gemini-2.5-pro_v1.2": "/gemini-2.5-pro_v1.2",
  71. "/a.b.c": "/a.b.c",
  72. }
  73. for suffix, want := range accepted {
  74. t.Run("accept_"+suffix, func(t *testing.T) {
  75. got, ok := sanitizedUpstreamPathSuffix(suffix)
  76. require.True(t, ok, "suffix %q must be accepted", suffix)
  77. require.Equal(t, want, got)
  78. })
  79. }
  80. }
  81. func TestSanitizedUpstreamPathSuffixEnforcesBounds(t *testing.T) {
  82. longSegment := "/"
  83. for i := 0; i < maxUpstreamPathSegmentLen+1; i++ {
  84. longSegment += "a"
  85. }
  86. _, ok := sanitizedUpstreamPathSuffix(longSegment)
  87. require.False(t, ok, "over-long segment must be rejected")
  88. deep := ""
  89. for i := 0; i <= maxUpstreamPathSegments; i++ {
  90. deep += "/a"
  91. }
  92. _, ok = sanitizedUpstreamPathSuffix(deep)
  93. require.False(t, ok, "over-deep suffix must be rejected")
  94. }
  95. // TestOpenAIResponsesRequestPathSuffixRejectsNonConformingSubpaths 锁定不变式:
  96. // /responses/*subpath 的子路径不得改变上游请求的路径结构;不合规时既不参与拼接,
  97. // 也不会被误判成 compact 请求。
  98. func TestOpenAIResponsesRequestPathSuffixRejectsNonConformingSubpaths(t *testing.T) {
  99. gin.SetMode(gin.TestMode)
  100. nonConformingPaths := []string{
  101. "/v1/responses/../../x/y",
  102. "/v1/responses/..%2f..%2fx/y",
  103. "/v1/responses/%2e%2e/%2e%2e/x",
  104. "/responses/%2e%2e%2fx",
  105. "/backend-api/codex/responses/../../../x",
  106. `/v1/responses/..\..\x`,
  107. "/v1/responses/%3fa=b",
  108. "/v1/responses/x%23frag",
  109. "/v1/responses//double",
  110. }
  111. for _, path := range nonConformingPaths {
  112. t.Run(path, func(t *testing.T) {
  113. c := newResponsesSuffixTestContext(t, path)
  114. require.False(t, IsForwardableOpenAIResponsesRequestPath(c),
  115. "path %q must be rejected at the gateway edge", path)
  116. require.Empty(t, openAIResponsesRequestPathSuffix(c),
  117. "path %q must never contribute an upstream path suffix", path)
  118. require.Equal(t, chatgptCodexURL,
  119. appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, openAIResponsesRequestPathSuffix(c)))
  120. require.False(t, isOpenAIResponsesCompactPath(c))
  121. })
  122. }
  123. // 合法子路径必须保持原样转发。
  124. for path, want := range map[string]string{
  125. "/v1/responses": "",
  126. "/v1/responses/compact": "/compact",
  127. "/v1/responses/input_tokens": "/input_tokens",
  128. "/responses/compact/": "/compact",
  129. "/backend-api/codex/responses/compact": "/compact",
  130. } {
  131. t.Run("forwardable_"+path, func(t *testing.T) {
  132. c := newResponsesSuffixTestContext(t, path)
  133. require.True(t, IsForwardableOpenAIResponsesRequestPath(c))
  134. require.Equal(t, want, openAIResponsesRequestPathSuffix(c))
  135. })
  136. }
  137. }
  138. func TestIsOpenAIResponsesInputTokensRequestPath(t *testing.T) {
  139. for _, path := range []string{"/v1/responses/input_tokens", "/responses/input_tokens", "/backend-api/codex/responses/input_tokens"} {
  140. c := newResponsesSuffixTestContext(t, path)
  141. require.True(t, IsOpenAIResponsesInputTokensRequestPath(c), "path=%s", path)
  142. }
  143. c := newResponsesSuffixTestContext(t, "/v1/responses/compact")
  144. require.False(t, IsOpenAIResponsesInputTokensRequestPath(c))
  145. }
  146. func TestIsOpenAIResponsesCompactPathUsesLegacyEndpointShape(t *testing.T) {
  147. legacyPaths := []string{
  148. "/v1/responses/compact",
  149. "/v1/responses/compact/detail",
  150. "/responses/compact/",
  151. }
  152. for _, path := range legacyPaths {
  153. t.Run("legacy_"+path, func(t *testing.T) {
  154. c := newResponsesSuffixTestContext(t, path)
  155. require.True(t, IsOpenAIResponsesCompactPath(c))
  156. })
  157. }
  158. nonLegacyPaths := []string{
  159. "/v1/responses",
  160. "/openai/v1/responses",
  161. "/responses",
  162. "/backend-api/codex/responses",
  163. "/v1/responses/resp_123/cancel",
  164. }
  165. for _, path := range nonLegacyPaths {
  166. t.Run("non_legacy_"+path, func(t *testing.T) {
  167. c := newResponsesSuffixTestContext(t, path)
  168. require.False(t, IsOpenAIResponsesCompactPath(c))
  169. })
  170. }
  171. }
  172. func TestAppendOpenAIResponsesRequestPathSuffixRefusesUnsafeSuffix(t *testing.T) {
  173. // 调用方漏了校验时,拼接函数本身也不得把不合规片段带进上游 URL。
  174. require.Equal(t, chatgptCodexURL, appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, "/../../x"))
  175. require.Equal(t, chatgptCodexURL, appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, "/?a=b"))
  176. require.Equal(t, chatgptCodexURL+"/compact", appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, "/compact"))
  177. }
  178. func newResponsesSuffixTestContext(t *testing.T, path string) *gin.Context {
  179. t.Helper()
  180. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  181. c.Request = httptest.NewRequest(http.MethodPost, path, nil)
  182. return c
  183. }