Add security headers and configurable rate limiting middleware

Change-Id: Id976e75e58d79ea38e7939afe7576a2295a1c5af
diff --git a/cmd/koralmapper/main.go b/cmd/koralmapper/main.go
index c1fbabe..9fc6f5d 100644
--- a/cmd/koralmapper/main.go
+++ b/cmd/koralmapper/main.go
@@ -20,6 +20,7 @@
 	"github.com/KorAP/Koral-Mapper/mapper"
 	"github.com/alecthomas/kong"
 	"github.com/gofiber/fiber/v2"
+	"github.com/gofiber/fiber/v2/middleware/limiter"
 	"github.com/rs/zerolog"
 	"github.com/rs/zerolog/log"
 )
@@ -309,6 +310,30 @@
 	configTmpl := template.Must(template.ParseFS(staticFS, "static/config.html"))
 	pluginTmpl := template.Must(template.ParseFS(staticFS, "static/plugin.html"))
 
+	// Security headers middleware to mitigate MIME-sniffing and referrer
+	// information leaks (OWASP Secure Headers). X-Frame-Options is
+	// intentionally omitted because the service is designed to be embedded
+	// in cross-origin iframes (Kalamar plugin).
+	app.Use(func(c *fiber.Ctx) error {
+		c.Set("X-Content-Type-Options", "nosniff")
+		c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
+		return c.Next()
+	})
+
+	// Rate limiting middleware to prevent resource exhaustion from
+	// request floods. The maximum number of requests per minute
+	// per IP is configurable via the "rateLimit" YAML key or the
+	// KORAL_MAPPER_RATE_LIMIT environment variable (default: 100).
+	rateLimit := yamlConfig.RateLimit
+	if rateLimit <= 0 {
+		rateLimit = 100
+	}
+	app.Use(limiter.New(limiter.Config{
+		Max:               rateLimit,
+		Expiration:        1 * time.Minute,
+		LimiterMiddleware: limiter.SlidingWindow{},
+	}))
+
 	// Health check endpoint
 	app.Get("/health", func(c *fiber.Ctx) error {
 		return c.SendString("OK")
diff --git a/cmd/koralmapper/main_test.go b/cmd/koralmapper/main_test.go
index 4543c8e..92318a3 100644
--- a/cmd/koralmapper/main_test.go
+++ b/cmd/koralmapper/main_test.go
@@ -2560,6 +2560,142 @@
 	assert.NotNil(t, wrap["rewrites"], "rewrites should be present when enabled by query param")
 }
 
+// TestSecurityHeadersPresent verifies that all HTTP responses include
+// security headers to mitigate MIME-sniffing and referrer leaks.
+// X-Frame-Options is intentionally NOT set because the service is
+// embedded in cross-origin iframes as a Kalamar plugin.
+func TestSecurityHeadersPresent(t *testing.T) {
+	mappingList := tmconfig.MappingList{
+		ID:       "test-mapper",
+		Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+	}
+
+	m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+	require.NoError(t, err)
+
+	mockConfig := &tmconfig.MappingConfig{Lists: []tmconfig.MappingList{mappingList}}
+	tmconfig.ApplyDefaults(mockConfig)
+
+	app := fiber.New()
+	setupRoutes(app, m, mockConfig)
+
+	endpoints := []struct {
+		method string
+		url    string
+		body   string
+	}{
+		{http.MethodGet, "/health", ""},
+		{http.MethodGet, "/", ""},
+		{http.MethodPost, "/test-mapper/query?dir=atob", `{"@type":"koral:token","wrap":{"@type":"koral:term","foundry":"x","key":"A","layer":"p","match":"match:eq"}}`},
+	}
+
+	for _, ep := range endpoints {
+		t.Run(ep.method+" "+ep.url, func(t *testing.T) {
+			var req *http.Request
+			if ep.body != "" {
+				req = httptest.NewRequest(ep.method, ep.url, bytes.NewBufferString(ep.body))
+				req.Header.Set("Content-Type", "application/json")
+			} else {
+				req = httptest.NewRequest(ep.method, ep.url, nil)
+			}
+
+			resp, err := app.Test(req)
+			require.NoError(t, err)
+			defer resp.Body.Close()
+
+			assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"),
+				"X-Content-Type-Options header must be set to nosniff")
+			assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"),
+				"Referrer-Policy header must be set")
+			assert.Empty(t, resp.Header.Get("X-Frame-Options"),
+				"X-Frame-Options must NOT be set (cross-origin iframe embedding)")
+		})
+	}
+}
+
+// TestRateLimitingEnforced verifies that the server applies per-client rate
+// limiting and returns HTTP 429 when the limit is exceeded.
+func TestRateLimitingEnforced(t *testing.T) {
+	mappingList := tmconfig.MappingList{
+		ID:       "test-mapper",
+		Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+	}
+
+	m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+	require.NoError(t, err)
+
+	mockConfig := &tmconfig.MappingConfig{Lists: []tmconfig.MappingList{mappingList}}
+	tmconfig.ApplyDefaults(mockConfig)
+
+	app := fiber.New()
+	setupRoutes(app, m, mockConfig)
+
+	// The default rate limit is 100/min. Send more requests than that
+	// to verify enforcement.
+	var lastStatus int
+	exceeded := false
+	for i := 0; i < 150; i++ {
+		req := httptest.NewRequest(http.MethodGet, "/health", nil)
+		resp, err := app.Test(req)
+		require.NoError(t, err)
+		resp.Body.Close()
+		lastStatus = resp.StatusCode
+		if lastStatus == fiber.StatusTooManyRequests {
+			exceeded = true
+			break
+		}
+	}
+
+	assert.True(t, exceeded, "rate limiter should return 429 when limit is exceeded, last status was %d", lastStatus)
+}
+
+// TestRateLimitingConfigurable verifies that the rate limit can be customized
+// via the rateLimit configuration field.
+func TestRateLimitingConfigurable(t *testing.T) {
+	mappingList := tmconfig.MappingList{
+		ID:       "test-mapper",
+		Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+	}
+
+	m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+	require.NoError(t, err)
+
+	// Set a very low rate limit to make testing fast
+	mockConfig := &tmconfig.MappingConfig{
+		RateLimit: 5,
+		Lists:     []tmconfig.MappingList{mappingList},
+	}
+	tmconfig.ApplyDefaults(mockConfig)
+
+	app := fiber.New()
+	setupRoutes(app, m, mockConfig)
+
+	// With a limit of 5, the 6th request should be rejected
+	for i := 0; i < 5; i++ {
+		req := httptest.NewRequest(http.MethodGet, "/health", nil)
+		resp, err := app.Test(req)
+		require.NoError(t, err)
+		resp.Body.Close()
+		assert.Equal(t, http.StatusOK, resp.StatusCode,
+			"request %d should succeed within the rate limit", i+1)
+	}
+
+	// The next request should exceed the limit
+	req := httptest.NewRequest(http.MethodGet, "/health", nil)
+	resp, err := app.Test(req)
+	require.NoError(t, err)
+	resp.Body.Close()
+	assert.Equal(t, fiber.StatusTooManyRequests, resp.StatusCode,
+		"request beyond the configured limit should return 429")
+}
+
+// TestRateLimitDefaultValue verifies the default rate limit is 100.
+func TestRateLimitDefaultValue(t *testing.T) {
+	cfg := &tmconfig.MappingConfig{}
+	tmconfig.ApplyDefaults(cfg)
+	assert.Equal(t, 100, cfg.RateLimit, "default rate limit should be 100 requests per minute")
+}
+
 func TestConfigPagePreservesOrderOfMappings(t *testing.T) {
 	lists := []tmconfig.MappingList{
 		{