From ac0d674c15abbac3e925349e347cf64e68170760 Mon Sep 17 00:00:00 2001 From: ispiroglu Date: Tue, 23 Sep 2025 11:30:25 +0300 Subject: [PATCH] feat: implement host-based access control for Swagger documentation - Added configurations for blocking and allowing hosts in Swagger. - Updated Swagger module documentation to reflect new access control features. - Enhanced middleware to enforce host restrictions based on configuration. - Added tests to validate host access control functionality. --- docs/modules/swagger.md | 66 +++- .../server/resources/configs/application.yml | 13 + modules/swagger/handler.go | 68 +++- modules/swagger/handler_test.go | 320 +++++++++++++++++- 4 files changed, 459 insertions(+), 8 deletions(-) diff --git a/docs/modules/swagger.md b/docs/modules/swagger.md index ae6bf12..3f15acd 100644 --- a/docs/modules/swagger.md +++ b/docs/modules/swagger.md @@ -102,10 +102,10 @@ The Swagger module generates the following JSON: ``` ### Config -The Swagger module offers simple configurations to customize the generated API documentation. Below are the default values: +The Swagger module offers configurations to customize the generated API documentation and control access. Below are the available configurations: +#### API Documentation Configuration ```yaml - info: description: "" version: 1.0.0 @@ -119,6 +119,68 @@ info: - Terms: A URL to the terms of service for your API. Defaults to http://swagger.io/terms/. - Email: The contact email for API support or inquiries. +#### Host-based Access Control +To protect your Swagger documentation from unauthorized access, you can configure host-based restrictions. This is particularly useful when your application is exposed to both internal and external networks. + +```yaml +server: + swagger: + # Block access when hostname contains any of these substrings + blockedHostsContains: + - "api.example.com" + - "external.company.com" + + # Optional: Only allow access when hostname contains any of these substrings + # If both allowedHostsContains and blockedHostsContains are set, + # blockedHostsContains takes precedence for better security + allowedHostsContains: + - "internal.company.com" + - "localhost" + - "127.0.0.1" +``` + +**Configuration Options:** +- `blockedHostsContains`: Array of strings. If the request hostname contains any of these substrings, access to Swagger will be denied with a 404 response. Takes precedence over allowlist for security. +- `allowedHostsContains`: Array of strings. If specified, only requests from hostnames containing these substrings will be allowed, unless they are also in the blocked list. + +**Host Detection:** +The middleware checks both the `Host` header and `X-Forwarded-Host` header (common when behind reverse proxies). The `X-Forwarded-Host` header takes precedence if present. + +**Example Use Cases:** + +1. **Block External Gateway Access:** +```yaml +server: + swagger: + blockedHostsContains: + - "apigw.company.com" + - "public.api.company.com" +``` + +2. **Internal-Only Access:** +```yaml +server: + swagger: + allowedHostsContains: + - "internal" + - "corp" + - "localhost" + - "127.0.0.1" +``` + +3. **Development Environment:** +```yaml +server: + swagger: + allowedHostsContains: + - "localhost" + - "127.0.0.1" + - "dev.company.com" + - "staging.company.com" +``` + +**Security Note:** When access is denied, the middleware returns a 404 status code instead of 403 to avoid revealing the existence of Swagger endpoints to unauthorized users. + diff --git a/example/server/resources/configs/application.yml b/example/server/resources/configs/application.yml index 91cf8e3..a4a3cca 100644 --- a/example/server/resources/configs/application.yml +++ b/example/server/resources/configs/application.yml @@ -3,6 +3,19 @@ server: # to be able to use wildcard, we need to declare it between double quotes. allowedOrigins: "*" allowCredentials: false + + # Swagger host-based access control + swagger: + # Block external access while allowing internal and development access + blockedHostsContains: + - "api.example.com" + - "external.company.com" + # Alternative: Use allowlist instead of blocklist + # allowedHostsContains: + # - "localhost" + # - "127.0.0.1" + # - "internal.company.com" + # - "dev.company.com" info: description: "this app is an example usage of Chaki." diff --git a/modules/swagger/handler.go b/modules/swagger/handler.go index 1483608..438d865 100644 --- a/modules/swagger/handler.go +++ b/modules/swagger/handler.go @@ -4,6 +4,7 @@ import ( "net/http" "strings" + "github.com/Trendyol/chaki/config" "github.com/Trendyol/chaki/modules/server/common" "github.com/Trendyol/chaki/modules/swagger/files" @@ -12,9 +13,10 @@ import ( "github.com/gofiber/fiber/v2/middleware/redirect" ) -func fiberWrapper(docs Docs) common.FiberAppWrapper { +func fiberWrapper(docs Docs, cfg *config.Config) common.FiberAppWrapper { return func(a *fiber.App) *fiber.App { a.Use( + newHostAccessMiddleware(cfg), newRedirectMiddleware(), newMiddleware(docs), ) @@ -42,14 +44,72 @@ func newMiddleware(docs Docs) fiber.Handler { return func(c *fiber.Ctx) error { if c.Path() == "/swagger/docs.json" || c.Path() == "/swagger/docs.json/" { - return c.JSON(docs.WithHost(c.Hostname())) + return c.JSON(docs.WithHost(getEffectiveHost(c))) } - if strings.HasPrefix(c.Path(), prefix) { - c.Path(strings.TrimPrefix(c.Path(), prefix)) + if after, ok := strings.CutPrefix(c.Path(), prefix); ok { + c.Path(after) return fsmw(c) } return c.Next() } } + +func newHostAccessMiddleware(cfg *config.Config) fiber.Handler { + serverCfg := cfg.Of("server") + if !serverCfg.Exists("swagger") { + return func(c *fiber.Ctx) error { return c.Next() } + } + + swcfg := serverCfg.Of("swagger") + var blocked, allowed []string + + if swcfg.Exists("blockedHostsContains") { + blocked = swcfg.GetStringSlice("blockedHostsContains") + } + if swcfg.Exists("allowedHostsContains") { + allowed = swcfg.GetStringSlice("allowedHostsContains") + } + + return func(c *fiber.Ctx) error { + if !isSwaggerRequest(c.Path()) { + return c.Next() + } + + host := getEffectiveHost(c) + + if len(blocked) > 0 && containsAny(host, blocked) { + return c.SendStatus(fiber.StatusNotFound) + } + + if len(allowed) > 0 && !containsAny(host, allowed) { + return c.SendStatus(fiber.StatusNotFound) + } + + return c.Next() + } +} + +func getEffectiveHost(c *fiber.Ctx) string { + if xfwd := c.Get("X-Forwarded-Host"); xfwd != "" { + return xfwd + } + return c.Hostname() +} + +func isSwaggerRequest(path string) bool { + if path == "/" || path == "/swagger" || path == "/swagger.json" || path == "/swagger/v1/swagger.json" { + return true + } + return strings.HasPrefix(path, "/swagger/") +} + +func containsAny(s string, subs []string) bool { + for _, sub := range subs { + if sub != "" && strings.Contains(s, sub) { + return true + } + } + return false +} diff --git a/modules/swagger/handler_test.go b/modules/swagger/handler_test.go index 48d511d..01f5316 100644 --- a/modules/swagger/handler_test.go +++ b/modules/swagger/handler_test.go @@ -7,17 +7,20 @@ import ( "net/http/httptest" "testing" + "github.com/Trendyol/chaki/config" "github.com/gofiber/fiber/v2" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" ) func Test_fiberWrapper(t *testing.T) { mockDocs := Docs{} + cfg := createTestConfig(map[string]interface{}{}) app := fiber.New() - wrappedApp := fiberWrapper(mockDocs)(app) + wrappedApp := fiberWrapper(mockDocs, cfg)(app) assert.NotNil(t, wrappedApp) - assert.Equal(t, uint32(2), wrappedApp.HandlersCount()) + assert.Equal(t, uint32(3), wrappedApp.HandlersCount()) // host middleware + redirect + swagger } func Test_newRedirectMiddleware(t *testing.T) { @@ -69,3 +72,316 @@ func Test_newMiddleware(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) }) } + +func createTestConfig(data map[string]interface{}) *config.Config { + v := viper.New() + for k, val := range data { + v.Set(k, val) + } + return config.NewConfig(v, map[string]*viper.Viper{}) +} + +func Test_newHostAccessMiddleware(t *testing.T) { + tests := []struct { + name string + config map[string]interface{} + host string + xForwardedHost string + path string + expectedStatus int + description string + }{ + { + name: "no swagger config - should allow all", + config: map[string]interface{}{}, + host: "api.example.com", + path: "/swagger/index.html", + expectedStatus: http.StatusOK, + description: "When no swagger config exists, all hosts should be allowed", + }, + { + name: "blocked host - should deny", + config: map[string]interface{}{ + "server.swagger.blockedHostsContains": []string{"api.example.com", "external.company.com"}, + }, + host: "api.example.com", + path: "/swagger/index.html", + expectedStatus: http.StatusNotFound, + description: "Host matching blocked list should be denied", + }, + { + name: "blocked host substring - should deny", + config: map[string]interface{}{ + "server.swagger.blockedHostsContains": []string{"example.com"}, + }, + host: "api.example.com", + path: "/swagger/index.html", + expectedStatus: http.StatusNotFound, + description: "Host containing blocked substring should be denied", + }, + { + name: "non-blocked host - should allow", + config: map[string]interface{}{ + "server.swagger.blockedHostsContains": []string{"external.company.com"}, + }, + host: "internal.company.com", + path: "/swagger/index.html", + expectedStatus: http.StatusOK, + description: "Host not in blocked list should be allowed", + }, + { + name: "allowed host only - should allow", + config: map[string]interface{}{ + "server.swagger.allowedHostsContains": []string{"internal", "localhost"}, + }, + host: "internal.company.com", + path: "/swagger/index.html", + expectedStatus: http.StatusOK, + description: "Host matching allowed list should be allowed", + }, + { + name: "not in allowed host list - should deny", + config: map[string]interface{}{ + "server.swagger.allowedHostsContains": []string{"internal", "localhost"}, + }, + host: "api.example.com", + path: "/swagger/index.html", + expectedStatus: http.StatusNotFound, + description: "Host not in allowed list should be denied", + }, + { + name: "blocked takes precedence over allowed for security", + config: map[string]interface{}{ + "server.swagger.allowedHostsContains": []string{"internal"}, + "server.swagger.blockedHostsContains": []string{"example.com"}, + }, + host: "internal.example.com", + path: "/swagger/index.html", + expectedStatus: http.StatusNotFound, + description: "Blocked list should take precedence over allowed list for better security", + }, + { + name: "x-forwarded-host takes precedence", + config: map[string]interface{}{ + "server.swagger.blockedHostsContains": []string{"api.example.com"}, + }, + host: "internal.company.com", + xForwardedHost: "api.example.com", + path: "/swagger/index.html", + expectedStatus: http.StatusNotFound, + description: "X-Forwarded-Host header should be used instead of Host", + }, + { + name: "non-swagger path - should allow", + config: map[string]interface{}{ + "server.swagger.blockedHostsContains": []string{"api.example.com"}, + }, + host: "api.example.com", + path: "/api/users", + expectedStatus: http.StatusOK, // Should reach the catch-all handler + description: "Non-swagger paths should not be affected by host restrictions", + }, + { + name: "root path redirect blocked", + config: map[string]interface{}{ + "server.swagger.blockedHostsContains": []string{"external"}, + }, + host: "external.api.com", + path: "/", + expectedStatus: http.StatusNotFound, + description: "Root path should be blocked when host is in blocked list", + }, + { + name: "swagger.json blocked", + config: map[string]interface{}{ + "server.swagger.blockedHostsContains": []string{"external"}, + }, + host: "external.api.com", + path: "/swagger.json", + expectedStatus: http.StatusNotFound, + description: "swagger.json endpoint should be blocked", + }, + { + name: "localhost development allowed", + config: map[string]interface{}{ + "server.swagger.allowedHostsContains": []string{"localhost", "127.0.0.1"}, + }, + host: "localhost:8080", + path: "/swagger/index.html", + expectedStatus: http.StatusOK, + description: "Localhost should be allowed for development", + }, + { + name: "empty blocked list item ignored", + config: map[string]interface{}{ + "server.swagger.blockedHostsContains": []string{"", "api.example.com"}, + }, + host: "any.host.com", + path: "/swagger/index.html", + expectedStatus: http.StatusOK, + description: "Empty strings in blocked list should be ignored", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := createTestConfig(tt.config) + middleware := newHostAccessMiddleware(cfg) + + app := fiber.New() + app.Use(middleware) + + // Add a simple handler to test middleware behavior + app.All("*", func(c *fiber.Ctx) error { + return c.SendStatus(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + req.Host = tt.host + if tt.xForwardedHost != "" { + req.Header.Set("X-Forwarded-Host", tt.xForwardedHost) + } + + resp, err := app.Test(req) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, tt.expectedStatus, resp.StatusCode, tt.description) + }) + } +} + +func Test_getEffectiveHost(t *testing.T) { + tests := []struct { + name string + host string + xForwardedHost string + expected string + }{ + { + name: "only host header", + host: "example.com", + expected: "example.com", + }, + { + name: "x-forwarded-host takes precedence", + host: "internal.com", + xForwardedHost: "external.com", + expected: "external.com", + }, + { + name: "empty x-forwarded-host falls back to host", + host: "example.com", + xForwardedHost: "", + expected: "example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := fiber.New() + var actualHost string + + app.Get("/test", func(c *fiber.Ctx) error { + actualHost = getEffectiveHost(c) + return c.SendStatus(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Host = tt.host + if tt.xForwardedHost != "" { + req.Header.Set("X-Forwarded-Host", tt.xForwardedHost) + } + + _, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, tt.expected, actualHost) + }) + } +} + +func Test_isSwaggerRequest(t *testing.T) { + tests := []struct { + path string + expected bool + }{ + {"/", true}, + {"/swagger", true}, + {"/swagger/", true}, + {"/swagger/index.html", true}, + {"/swagger/docs.json", true}, + {"/swagger.json", true}, + {"/swagger/v1/swagger.json", true}, + {"/swagger/assets/style.css", true}, + {"/api/users", false}, + {"/health", false}, + {"/metrics", false}, + {"/swaggerx", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + actual := isSwaggerRequest(tt.path) + assert.Equal(t, tt.expected, actual) + }) + } +} + +func Test_containsAny(t *testing.T) { + tests := []struct { + name string + s string + subs []string + expected bool + }{ + { + name: "exact match", + s: "example.com", + subs: []string{"example.com"}, + expected: true, + }, + { + name: "substring match", + s: "api.example.com", + subs: []string{"example.com"}, + expected: true, + }, + { + name: "multiple substrings - first matches", + s: "api.example.com", + subs: []string{"example.com", "test.com"}, + expected: true, + }, + { + name: "multiple substrings - second matches", + s: "api.test.com", + subs: []string{"example.com", "test.com"}, + expected: true, + }, + { + name: "no match", + s: "internal.company.com", + subs: []string{"example.com", "test.com"}, + expected: false, + }, + { + name: "empty substring ignored", + s: "example.com", + subs: []string{"", "nomatch"}, + expected: false, + }, + { + name: "empty string input", + s: "", + subs: []string{"example.com"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := containsAny(tt.s, tt.subs) + assert.Equal(t, tt.expected, actual) + }) + } +}