Add 12 test files covering all internal packages: - internal/util/util_test.go (97.2%) - internal/model/model_test.go (100.0%) - internal/config/config_test.go (88.1%) - internal/template/builtin_test.go (98.8%) - internal/middleware/middleware_test.go (98.7%) - internal/database/repo_extra_test.go (85.5%) - internal/rules/converter_test.go (99.1%) - internal/service/subscription_test.go (75.7%) - internal/handler/handler_test.go (90.6%) - internal/filter/filter_extra_test.go (91.9%) - internal/proxy/client_parser_test.go (96.1%) - internal/render/render_extra_test.go (99.2%) Overall: 91.8% (4033/4400 statements) — exceeds 85% acceptance threshold. All tests pass, go vet clean, go build clean.
829 lines
22 KiB
Go
829 lines
22 KiB
Go
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))
|
|
}
|
|
}
|