package middleware import ( "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/gofiber/fiber/v3" ) // newTestApp creates a fiber app, registers the given middleware via Use, // then a final GET handler that returns 200 "ok". // The last argument is the final route handler; preceding args are middleware. func newTestApp(t *testing.T, handlers ...fiber.Handler) *fiber.App { t.Helper() app := fiber.New() mws := handlers if len(mws) > 0 { middlewares := make([]any, len(mws)) for i, h := range mws { middlewares[i] = h } app.Use(middlewares...) } app.Get("/test", func(c fiber.Ctx) error { return c.SendString("ok") }) return app } // newTestAppWithHandler creates a fiber app with middleware and a custom final route handler. // The last argument is the final route handler; preceding args are middleware. func newTestAppWithHandler(t *testing.T, handlers ...fiber.Handler) *fiber.App { t.Helper() app := fiber.New() if len(handlers) == 0 { app.Get("/test", func(c fiber.Ctx) error { return c.SendString("ok") }) return app } mws := handlers[:len(handlers)-1] final := handlers[len(handlers)-1] if len(mws) > 0 { middlewares := make([]any, len(mws)) for i, h := range mws { middlewares[i] = h } app.Use(middlewares...) } app.Get("/test", final) return app } // handlersToAny converts []fiber.Handler to []any for variadic registration. func handlersToAny(hs []fiber.Handler) []any { out := make([]any, len(hs)) for i, h := range hs { out[i] = h } return out } // ---------- auth.go ---------- func TestExtractToken(t *testing.T) { tests := []struct { name string setReq func(*http.Request) want string }{ { name: "Bearer header", setReq: func(r *http.Request) { r.Header.Set("Authorization", "Bearer my-token-123") }, want: "my-token-123", }, { name: "bearer lowercase", setReq: func(r *http.Request) { r.Header.Set("Authorization", "bearer lower-token") }, want: "lower-token", }, { name: "Bearer with extra spaces", setReq: func(r *http.Request) { r.Header.Set("Authorization", "Bearer spaced-token ") }, want: "spaced-token", }, { name: "query param token", setReq: func(r *http.Request) { q := r.URL.Query() q.Set("token", "query-token") r.URL.RawQuery = q.Encode() }, want: "query-token", }, { name: "x-sub-store-token header", setReq: func(r *http.Request) { r.Header.Set("X-Sub-Store-Token", "header-token") }, want: "header-token", }, { name: "Bearer takes priority over query", setReq: func(r *http.Request) { r.Header.Set("Authorization", "Bearer bearer-wins") q := r.URL.Query() q.Set("token", "query-loses") r.URL.RawQuery = q.Encode() }, want: "bearer-wins", }, { name: "query takes priority over x-sub-store-token", setReq: func(r *http.Request) { q := r.URL.Query() q.Set("token", "query-wins") r.URL.RawQuery = q.Encode() r.Header.Set("X-Sub-Store-Token", "header-loses") }, want: "query-wins", }, { name: "no token anywhere", setReq: func(r *http.Request) {}, want: "", }, { name: "Authorization without Bearer prefix", setReq: func(r *http.Request) { r.Header.Set("Authorization", "Basic abc123") }, want: "", }, { name: "empty Bearer", setReq: func(r *http.Request) { r.Header.Set("Authorization", "Bearer ") }, want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var got string app := fiber.New() app.Get("/test", func(c fiber.Ctx) error { got = ExtractToken(c) return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) tt.setReq(req) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } resp.Body.Close() if got != tt.want { t.Errorf("ExtractToken() = %q, want %q", got, tt.want) } }) } } func TestRequireAdmin(t *testing.T) { adminToken := "secret-admin-token" t.Run("valid token passes", func(t *testing.T) { app := newTestAppWithHandler(t, RequireAdmin(adminToken), func(c fiber.Ctx) error { return c.SendString("protected") }) req := httptest.NewRequest("GET", "/test", nil) req.Header.Set("Authorization", "Bearer "+adminToken) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 200 { t.Errorf("status = %d, want 200", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if string(body) != "protected" { t.Errorf("body = %q, want protected", string(body)) } resp.Body.Close() }) t.Run("invalid token returns 401", func(t *testing.T) { app := newTestAppWithHandler(t, RequireAdmin(adminToken), func(c fiber.Ctx) error { return c.SendString("protected") }) req := httptest.NewRequest("GET", "/test", nil) req.Header.Set("Authorization", "Bearer wrong-token") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 401 { t.Errorf("status = %d, want 401", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "invalid") && !strings.Contains(string(body), "failed") { t.Errorf("body should mention invalid, got: %s", string(body)) } resp.Body.Close() }) t.Run("no token returns 401", func(t *testing.T) { app := newTestAppWithHandler(t, RequireAdmin(adminToken), func(c fiber.Ctx) error { return c.SendString("protected") }) req := httptest.NewRequest("GET", "/test", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 401 { t.Errorf("status = %d, want 401", resp.StatusCode) } resp.Body.Close() }) t.Run("token via query param", func(t *testing.T) { app := newTestAppWithHandler(t, RequireAdmin(adminToken), func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test?token="+adminToken, nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 200 { t.Errorf("status = %d, want 200", resp.StatusCode) } resp.Body.Close() }) t.Run("token via x-sub-store-token header", func(t *testing.T) { app := newTestAppWithHandler(t, RequireAdmin(adminToken), func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) req.Header.Set("X-Sub-Store-Token", adminToken) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 200 { t.Errorf("status = %d, want 200", resp.StatusCode) } resp.Body.Close() }) } // ---------- cors.go ---------- func TestCORS(t *testing.T) { t.Run("allowed origin wildcard", func(t *testing.T) { app := newTestAppWithHandler(t, CORS("*"), func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) req.Header.Set("Origin", "https://example.com") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://example.com" { t.Errorf("ACAO = %q, want https://example.com", got) } if got := resp.Header.Get("Access-Control-Allow-Methods"); got == "" { t.Error("Access-Control-Allow-Methods should be set") } if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") { t.Errorf("Access-Control-Allow-Headers should contain Authorization, got %q", got) } resp.Body.Close() }) t.Run("allowed origin specific", func(t *testing.T) { app := newTestAppWithHandler(t, CORS("https://allowed.com, https://also.com"), func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) req.Header.Set("Origin", "https://allowed.com") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://allowed.com" { t.Errorf("ACAO = %q, want https://allowed.com", got) } resp.Body.Close() }) t.Run("disallowed origin", func(t *testing.T) { app := newTestAppWithHandler(t, CORS("https://allowed.com"), func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) req.Header.Set("Origin", "https://evil.com") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("ACAO = %q, want empty (disallowed)", got) } resp.Body.Close() }) t.Run("no origin header", func(t *testing.T) { app := newTestAppWithHandler(t, CORS("*"), func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("ACAO = %q, want empty (no origin)", got) } resp.Body.Close() }) t.Run("empty allowed origins", func(t *testing.T) { app := newTestAppWithHandler(t, CORS(""), func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) req.Header.Set("Origin", "https://example.com") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("ACAO = %q, want empty (no origins configured)", got) } resp.Body.Close() }) } func TestHandleOptions(t *testing.T) { t.Run("OPTIONS preflight returns 204", func(t *testing.T) { app := fiber.New() app.Use(HandleOptions()) app.Get("/test", func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("OPTIONS", "/test", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 204 { t.Errorf("status = %d, want 204", resp.StatusCode) } if got := resp.Header.Get("Access-Control-Allow-Methods"); got == "" { t.Error("Access-Control-Allow-Methods should be set for OPTIONS") } if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "Authorization") { t.Errorf("Access-Control-Allow-Headers should contain Authorization, got %q", got) } resp.Body.Close() }) t.Run("GET passes through", func(t *testing.T) { app := fiber.New() app.Use(HandleOptions()) app.Get("/test", func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 200 { t.Errorf("status = %d, want 200", resp.StatusCode) } resp.Body.Close() }) } func TestParseOrigins(t *testing.T) { tests := []struct { name string input string want []string }{ {"single", "https://example.com", []string{"https://example.com"}}, {"multiple", "https://a.com, https://b.com", []string{"https://a.com", "https://b.com"}}, {"with spaces", " https://a.com , https://b.com ", []string{"https://a.com", "https://b.com"}}, {"empty", "", []string{}}, {"only commas", ",,,", []string{}}, {"wildcard", "*", []string{"*"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := parseOrigins(tt.input) if len(got) != len(tt.want) { t.Errorf("parseOrigins(%q) = %v, want %v", tt.input, got, tt.want) return } for i := range got { if got[i] != tt.want[i] { t.Errorf("parseOrigins(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) } } }) } } func TestContainsHelper(t *testing.T) { list := []string{"a", "b", "c"} if !contains(list, "a") { t.Error("contains should find 'a'") } if contains(list, "z") { t.Error("contains should not find 'z'") } if contains([]string{}, "a") { t.Error("contains on empty list should be false") } } // ---------- security.go ---------- func TestSecurityHeaders(t *testing.T) { app := newTestAppWithHandler(t, SecurityHeaders(), func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } defer resp.Body.Close() csp := resp.Header.Get("Content-Security-Policy") if csp == "" { t.Fatal("Content-Security-Policy header should be set") } // Per review-resolution #27: CSP must NOT contain unsafe-eval if strings.Contains(csp, "unsafe-eval") { t.Errorf("CSP should not contain unsafe-eval: %s", csp) } // Should contain script-src 'self' if !strings.Contains(csp, "script-src 'self'") { t.Errorf("CSP should contain script-src 'self': %s", csp) } // Check other security headers if got := resp.Header.Get("Referrer-Policy"); got != "no-referrer" { t.Errorf("Referrer-Policy = %q, want no-referrer", got) } if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" { t.Errorf("X-Content-Type-Options = %q, want nosniff", got) } if got := resp.Header.Get("X-Frame-Options"); got != "DENY" { t.Errorf("X-Frame-Options = %q, want DENY", got) } pp := resp.Header.Get("Permissions-Policy") if pp == "" { t.Error("Permissions-Policy should be set") } if !strings.Contains(pp, "camera=()") { t.Errorf("Permissions-Policy should contain camera=(): %s", pp) } } func TestBodyLimit(t *testing.T) { t.Run("within limit", func(t *testing.T) { app := fiber.New() app.Post("/test", BodyLimit(100), func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("POST", "/test", strings.NewReader("small body")) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 200 { t.Errorf("status = %d, want 200", resp.StatusCode) } resp.Body.Close() }) t.Run("exceeds limit", func(t *testing.T) { app := fiber.New() app.Post("/test", BodyLimit(10), func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("POST", "/test", strings.NewReader(strings.Repeat("x", 100))) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 413 { t.Errorf("status = %d, want 413", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "too large") { t.Errorf("body should mention too large: %s", string(body)) } resp.Body.Close() }) } func TestDownloadHostIsolation(t *testing.T) { t.Run("no download hosts configured - all allowed", func(t *testing.T) { app := fiber.New() app.Use(DownloadHostIsolation(nil)) app.Get("/api/test", func(c fiber.Ctx) error { return c.SendString("ok") }) req := httptest.NewRequest("GET", "/api/test", nil) req.Host = "anyhost.com" resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 200 { t.Errorf("status = %d, want 200", resp.StatusCode) } resp.Body.Close() }) t.Run("download host serves /download/ path", func(t *testing.T) { app := fiber.New() app.Use(DownloadHostIsolation([]string{"dl.example.com"})) app.Get("/download/sub", func(c fiber.Ctx) error { return c.SendString("download") }) req := httptest.NewRequest("GET", "/download/sub", nil) req.Host = "dl.example.com" resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 200 { t.Errorf("status = %d, want 200", resp.StatusCode) } resp.Body.Close() }) t.Run("download host blocked on non-download path", func(t *testing.T) { app := fiber.New() app.Use(DownloadHostIsolation([]string{"dl.example.com"})) app.Get("/api/test", func(c fiber.Ctx) error { return c.SendString("api") }) req := httptest.NewRequest("GET", "/api/test", nil) req.Host = "dl.example.com" resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 404 { t.Errorf("status = %d, want 404", resp.StatusCode) } resp.Body.Close() }) t.Run("non-download host allowed on any path", func(t *testing.T) { app := fiber.New() app.Use(DownloadHostIsolation([]string{"dl.example.com"})) app.Get("/api/test", func(c fiber.Ctx) error { return c.SendString("api") }) req := httptest.NewRequest("GET", "/api/test", nil) req.Host = "api.example.com" resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 200 { t.Errorf("status = %d, want 200", resp.StatusCode) } resp.Body.Close() }) t.Run("download host case-insensitive", func(t *testing.T) { app := fiber.New() app.Use(DownloadHostIsolation([]string{"DL.Example.COM"})) app.Get("/api/test", func(c fiber.Ctx) error { return c.SendString("api") }) req := httptest.NewRequest("GET", "/api/test", nil) req.Host = "dl.example.com" resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if resp.StatusCode != 404 { t.Errorf("status = %d, want 404 (case-insensitive match)", resp.StatusCode) } resp.Body.Close() }) } func TestSetSafeResponseHeader(t *testing.T) { t.Run("valid value sets header", func(t *testing.T) { app := fiber.New() app.Get("/test", func(c fiber.Ctx) error { SetSafeResponseHeader(c, "X-Custom", "safe-value") return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if got := resp.Header.Get("X-Custom"); got != "safe-value" { t.Errorf("X-Custom = %q, want safe-value", got) } resp.Body.Close() }) t.Run("empty value does not set header", func(t *testing.T) { app := fiber.New() app.Get("/test", func(c fiber.Ctx) error { SetSafeResponseHeader(c, "X-Custom", "") return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if got := resp.Header.Get("X-Custom"); got != "" { t.Errorf("X-Custom = %q, want empty (not set)", got) } resp.Body.Close() }) t.Run("CRLF injection blocked", func(t *testing.T) { app := fiber.New() app.Get("/test", func(c fiber.Ctx) error { SetSafeResponseHeader(c, "X-Custom", "safe\r\nX-Injected: evil") return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if got := resp.Header.Get("X-Custom"); got != "" { t.Errorf("X-Custom = %q, want empty (CRLF blocked)", got) } if got := resp.Header.Get("X-Injected"); got != "" { t.Errorf("X-Injected = %q, want empty (injection blocked)", got) } resp.Body.Close() }) t.Run("LF only blocked", func(t *testing.T) { app := fiber.New() app.Get("/test", func(c fiber.Ctx) error { SetSafeResponseHeader(c, "X-Custom", "safe\nX-Injected: evil") return c.SendString("ok") }) req := httptest.NewRequest("GET", "/test", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test error: %v", err) } if got := resp.Header.Get("X-Custom"); got != "" { t.Errorf("X-Custom = %q, want empty (LF blocked)", got) } resp.Body.Close() }) } func TestSafeContentDisposition(t *testing.T) { tests := []struct { name string input string want string }{ { name: "simple filename", input: `attachment; filename="test.txt"`, want: `attachment; filename="test.txt"`, }, { name: "filename without quotes", input: `attachment; filename=test.txt`, want: `attachment; filename="test.txt"`, }, { name: "UTF-8 encoded filename", input: `attachment; filename*=UTF-8''test%20file.txt`, want: `attachment; filename="test_20file.txt"`, }, { name: "empty input", input: "", want: "", }, { name: "CRLF injection attempt", input: "attachment; filename=\"test\r\nX-Injected: evil\"", want: "", }, { name: "no filename", input: "attachment", want: "", }, { name: "special chars sanitized", input: `attachment; filename="test@#$%.txt"`, want: `attachment; filename="test____.txt"`, }, { name: "CJK preserved", input: `attachment; filename="节点.txt"`, want: `attachment; filename="节点.txt"`, }, { name: "long filename truncated", input: `attachment; filename="` + strings.Repeat("a", 200) + `.txt"`, want: `attachment; filename="` + strings.Repeat("a", 120) + `"`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := SafeContentDisposition(tt.input) if got != tt.want { t.Errorf("SafeContentDisposition(%q) = %q, want %q", tt.input, got, tt.want) } }) } } func TestSafeContentDispositionEmptyFilename(t *testing.T) { // filename with only special chars → sanitized to underscores (non-empty) → returns valid got := SafeContentDisposition(`attachment; filename="!!!"`) want := `attachment; filename="___"` if got != want { t.Errorf("SafeContentDisposition with only-special filename = %q, want %q", got, want) } } func TestExtractFilename(t *testing.T) { tests := []struct { input string want string }{ {`attachment; filename="test.txt"`, "test.txt"}, {`attachment; filename=test.txt`, "test.txt"}, {`attachment; filename*=UTF-8''test.txt`, "test.txt"}, {`attachment`, ""}, {``, ""}, {`filename="only"`, "only"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := extractFilename(tt.input) if got != tt.want { t.Errorf("extractFilename(%q) = %q, want %q", tt.input, got, tt.want) } }) } } func TestSanitizeFilename(t *testing.T) { tests := []struct { input string want string }{ {"simple.txt", "simple.txt"}, {"test@file.txt", "test_file.txt"}, {"节点.txt", "节点.txt"}, // CJK preserved {"a b c.txt", "a b c.txt"}, // spaces preserved {"test(1).txt", "test(1).txt"}, // parens preserved {"test-1.txt", "test-1.txt"}, // hyphens preserved } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := sanitizeFilename(tt.input) if got != tt.want { t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.want) } }) } // truncation long := strings.Repeat("a", 200) got := sanitizeFilename(long) if len(got) > 120 { t.Errorf("sanitizeFilename long input length = %d, want <= 120", len(got)) } }