Files
ttstd-infinite-canvas/backend/internal/handler/security_test.go

221 lines
13 KiB
Go

package handler
import (
"mime/multipart"
"net/http"
"net/url"
"strings"
"testing"
"yingce/backend/internal/model"
)
func TestPasswordResetRateLimitSubjectNormalizesAndHashesEmail(t *testing.T) {
first := passwordResetRateLimitSubject(" Creator@Example.com ")
second := passwordResetRateLimitSubject("creator@example.com")
if first != second {
t.Fatalf("normalized subjects differ: %q != %q", first, second)
}
if first == "creator@example.com" || strings.Contains(first, "@") || len(first) != 64 {
t.Fatalf("rate limit subject is not a SHA-256 digest: %q", first)
}
}
func TestAuthorizeSystemProxyAllowsConfiguredGenerationModel(t *testing.T) {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["gpt-image-1"]`}
body := []byte(`{"model":"gpt-image-1","prompt":"test"}`)
if err := authorizeSystemProxy(channel, model.ChannelInterfaceOpenAIImage, http.MethodPost, "/images/generations", "application/json", body); err != nil {
t.Fatalf("authorizeSystemProxy() error = %v", err)
}
}
func TestAuthorizeSystemProxyAllowsGrokImageJSONEdits(t *testing.T) {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["grok-imagine-image"]`}
body := []byte(`{"model":"grok-imagine-image","prompt":"edit"}`)
if err := authorizeSystemProxy(channel, model.ChannelInterfaceGrokImage, http.MethodPost, "/images/edits", "application/json", body); err != nil {
t.Fatalf("authorizeSystemProxy() error = %v", err)
}
}
func TestAuthorizeCustomRelayAllowsModelsAndAgentEndpoints(t *testing.T) {
tests := []struct {
method string
target string
apiFormat string
contentType string
}{
{method: http.MethodGet, target: "https://api.example.com/v1/models", apiFormat: "openai"},
{method: http.MethodPost, target: "https://api.example.com/v1/responses", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/chat/completions", apiFormat: "openai", contentType: "application/json; charset=utf-8"},
{method: http.MethodPost, target: "https://api.anthropic.com/v1/messages", apiFormat: "claude", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/audio/speech", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/images/edits", apiFormat: "openai", contentType: "multipart/form-data; boundary=test"},
{method: http.MethodPost, target: "https://api.example.com/v1/images/edits", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/videos", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/videos", apiFormat: "openai", contentType: "multipart/form-data; boundary=test"},
{method: http.MethodGet, target: "https://api.example.com/v1/videos/task-1/content", apiFormat: "openai"},
{method: http.MethodPost, target: "https://api.example.com/v1/video/generations", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodGet, target: "https://api.example.com/v1/video/generations/task-1", apiFormat: "openai"},
{method: http.MethodPost, target: "https://api.x.ai/v1/videos/generations", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodGet, target: "https://api.x.ai/v1/videos/request-1", apiFormat: "openai"},
{method: http.MethodPost, target: "https://api.novita.ai/v3/video/create", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodGet, target: "https://api.novita.ai/v3/async/task-result?task_id=task-1", apiFormat: "openai"},
{method: http.MethodPost, target: "https://ark.cn-beijing.volces.com/api/v3/images/generations", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodGet, target: "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/task-1", apiFormat: "openai"},
{method: http.MethodPost, target: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", apiFormat: "gemini", contentType: "application/json"},
{method: http.MethodPost, target: "https://generativelanguage.googleapis.com/v1beta/models/veo-3.0-generate-preview:predictLongRunning", apiFormat: "gemini", contentType: "application/json"},
{method: http.MethodGet, target: "https://generativelanguage.googleapis.com/v1beta/operations/operation-1", apiFormat: "gemini"},
}
for _, test := range tests {
target, err := url.Parse(test.target)
if err != nil {
t.Fatal(err)
}
if err := authorizeCustomRelay(test.method, target, test.apiFormat, test.contentType); err != nil {
t.Fatalf("authorizeCustomRelay(%s %s) error = %v", test.method, test.target, err)
}
}
}
func TestAuthorizeCustomRelayAllowsOnlyOfficialAgnesPollQuery(t *testing.T) {
target, err := url.Parse("https://apihub.agnes-ai.com/agnesapi?video_id=video-1&model_name=agnes-video-2.5")
if err != nil {
t.Fatal(err)
}
if err := authorizeCustomRelay(http.MethodGet, target, "openai", ""); err != nil {
t.Fatalf("authorizeCustomRelay() Agnes poll error = %v", err)
}
for _, raw := range []string{
"https://apihub.agnes-ai.com/agnesapi?video_id=video-1",
"https://apihub.agnes-ai.com/agnesapi?video_id=video-1&model_name=agnes-video-2.5&extra=1",
} {
invalid, parseErr := url.Parse(raw)
if parseErr != nil {
t.Fatal(parseErr)
}
if err := authorizeCustomRelay(http.MethodGet, invalid, "openai", ""); err == nil {
t.Fatalf("authorizeCustomRelay(%q) should fail", raw)
}
}
}
func TestAuthorizeCustomRelayRejectsArbitraryRequestsAndCredentialQueries(t *testing.T) {
tests := []struct {
method string
target string
apiFormat string
contentType string
}{
{method: http.MethodDelete, target: "https://api.example.com/v1/models", apiFormat: "openai"},
{method: http.MethodGet, target: "https://api.example.com/account", apiFormat: "openai"},
{method: http.MethodGet, target: "https://api.example.com/v1/models?api_key=secret", apiFormat: "openai"},
{method: http.MethodPost, target: "https://api.example.com/v1/responses", apiFormat: "openai", contentType: "text/plain"},
{method: http.MethodPost, target: "https://api.anthropic.com/v1/messages", apiFormat: "claude", contentType: "text/plain"},
{method: http.MethodGet, target: "https://api.anthropic.com/v1/messages", apiFormat: "claude"},
{method: http.MethodPost, target: "https://api.example.com/v1/account", apiFormat: "openai", contentType: "multipart/form-data; boundary=test"},
{method: http.MethodPost, target: "https://api.example.com/v1/../account/chat/completions", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/models/gemini:streamGenerateContent?alt=sse&token=secret", apiFormat: "gemini", contentType: "application/json"},
{method: http.MethodGet, target: "https://api.novita.ai/v3/async/task-result?task_id=task-1&api_key=secret", apiFormat: "openai"},
{method: http.MethodGet, target: "https://api.novita.ai/v3/async/task-result", apiFormat: "openai"},
}
for _, test := range tests {
target, err := url.Parse(test.target)
if err != nil {
t.Fatal(err)
}
if err := authorizeCustomRelay(test.method, target, test.apiFormat, test.contentType); err == nil {
t.Fatalf("authorizeCustomRelay(%s %s) should fail", test.method, test.target)
}
}
}
func TestAuthorizeSystemProxyRejectsArbitraryPathAndModel(t *testing.T) {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["gpt-image-1"]`}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceOpenAIImage, http.MethodDelete, "/account", "application/json", nil); err == nil {
t.Fatal("expected arbitrary path to be rejected")
}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceOpenAIImage, http.MethodPost, "/images/generations", "application/json", []byte(`{"model":"unapproved"}`)); err == nil {
t.Fatal("expected unapproved model to be rejected")
}
}
func TestProxyRequestModelReadsMultipartField(t *testing.T) {
var body strings.Builder
writer := multipart.NewWriter(&body)
_ = writer.WriteField("model", "gpt-image-1")
_ = writer.Close()
if got := proxyRequestModel(writer.FormDataContentType(), []byte(body.String())); got != "gpt-image-1" {
t.Fatalf("proxyRequestModel() = %q", got)
}
}
func TestAuthorizeSystemProxyRestrictsModelProtocol(t *testing.T) {
body := []byte(`{"model":"gpt-4.1"}`)
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["gpt-4.1"]`}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceChatCompletion, http.MethodPost, "/chat/completions", "application/json", body); err != nil {
t.Fatalf("authorizeSystemProxy() error = %v", err)
}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceChatCompletion, http.MethodPost, "/responses", "application/json", body); err == nil {
t.Fatal("authorizeSystemProxy() error = nil for mismatched interface")
}
}
func TestAuthorizeSystemProxyMiniMaxVideoCreateAndPoll(t *testing.T) {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["MiniMax-H3"]`}
createBody := []byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"test"}]}`)
if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodPost, "/v2/video_generation", "application/json", createBody); err != nil {
t.Fatalf("MiniMax create should be allowed: %v", err)
}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodGet, "/v2/query/video_generation/task-1", "", nil); err != nil {
t.Fatalf("MiniMax poll should be allowed: %v", err)
}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodGet, "/v2/account", "", nil); err == nil {
t.Fatal("arbitrary MiniMax GET should be rejected")
}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodPost, "/v2/video_generation", "text/plain", createBody); err == nil {
t.Fatal("MiniMax non-JSON create should be rejected")
}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceMiniMaxVideo, http.MethodPost, "/v2/video_generation", "application/json", []byte(`{"model":"unapproved"}`)); err == nil {
t.Fatal("unapproved MiniMax model should be rejected")
}
}
func TestAuthorizeSystemProxyAgnesVideoCreateAndPoll(t *testing.T) {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["agnes-video-2.5"]`}
createBody := []byte(`{"model":"agnes-video-2.5","prompt":"test","mode":"text","seconds":"5","size":"720P"}`)
if err := authorizeSystemProxy(channel, model.ChannelInterfaceAgnesVideo, http.MethodPost, "/videos", "application/json", createBody); err != nil {
t.Fatalf("Agnes create authorization error = %v", err)
}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceAgnesVideo, http.MethodGet, "/agnesapi", "", nil); err != nil {
t.Fatalf("Agnes poll authorization error = %v", err)
}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceAgnesVideo, http.MethodPost, "/videos", "multipart/form-data; boundary=test", createBody); err == nil {
t.Fatal("expected Agnes multipart create to be rejected")
}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceAgnesVideo, http.MethodGet, "/videos/task-1", "", nil); err == nil {
t.Fatal("expected unsupported Agnes poll path to be rejected")
}
}
func TestAuthorizeSystemProxyVolcengineArkImageOnlyAllowsGenerations(t *testing.T) {
body := []byte(`{"model":"doubao-seedream-test"}`)
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["doubao-seedream-test"]`}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceVolcengineArkImage, http.MethodPost, "/images/generations", "application/json", body); err != nil {
t.Fatalf("authorizeSystemProxy() error = %v", err)
}
if err := authorizeSystemProxy(channel, model.ChannelInterfaceVolcengineArkImage, http.MethodPost, "/images/edits", "application/json", body); err == nil {
t.Fatal("authorizeSystemProxy() error = nil for unsupported Ark image edits path")
}
}
func TestAuthorizeSystemProxyBlocksBackendOnlyVideoInterfaces(t *testing.T) {
body := []byte(`{"model":"grok-image-video"}`)
for _, interfaceType := range []model.ChannelInterfaceType{model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo, model.ChannelInterfaceVolcengineJiMengImage, model.ChannelInterfaceVolcengineJiMengVideo} {
channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["grok-image-video"]`}
if err := authorizeSystemProxy(channel, interfaceType, http.MethodPost, "/video/generations", "application/json", body); err == nil {
t.Fatalf("authorizeSystemProxy() error = nil for backend-only interface %q", interfaceType)
}
}
}