Add security headers and configurable rate limiting middleware

Change-Id: Id976e75e58d79ea38e7939afe7576a2295a1c5af
diff --git a/README.md b/README.md
index af43b62..0b71410 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,9 @@
 # Optional: ServiceURL for the koralmapper
 serviceURL: "https://korap.ids-mannheim.de/plugin/koralmapper"
 
+# Optional: Maximum requests per minute per IP for rate limiting (default: 100)
+rateLimit: 100
+
 # Optional: Mapping lists (same format as individual mapping files)
 lists:
   - id: mapping-list-id
@@ -99,6 +102,7 @@
 - **`port`**: Server port (default: `5725`)
 - **`loglevel`**: Log level (default: `warn`)
 - **`serviceURL`**: Service URL of the KoralMapper (default: `https://korap.ids-mannheim.de/plugin/koralmapper`)
+- **`rateLimit`**: Maximum number of requests per minute per IP address (default: `100`). When the limit is exceeded, the server responds with HTTP 429 (Too Many Requests).
 
 These values are applied during configuration parsing. When using only individual mapping files (`-m` flags), default values are used unless overridden by command line arguments.
 
@@ -114,6 +118,7 @@
 - `KORAL_MAPPER_COOKIE_NAME`: Overrides `cookieName`
 - `KORAL_MAPPER_LOG_LEVEL`: Overrides `loglevel`
 - `KORAL_MAPPER_PORT`: Overrides `port` (integer)
+- `KORAL_MAPPER_RATE_LIMIT`: Overrides `rateLimit` (integer, requests per minute per IP)
 
 Environment variable values take precedence over values from the configuration file.
 
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{
 		{
diff --git a/config/config.go b/config/config.go
index b7d2a16..2ad6c43 100644
--- a/config/config.go
+++ b/config/config.go
@@ -19,6 +19,7 @@
 	defaultCookieName = "km-config"
 	defaultPort       = 5725
 	defaultLogLevel   = "warn"
+	defaultRateLimit  = 100
 )
 
 // MappingRule represents a single mapping rule in the configuration
@@ -96,6 +97,7 @@
 	CookieName string        `yaml:"cookieName,omitempty"`
 	Port       int           `yaml:"port,omitempty"`
 	LogLevel   string        `yaml:"loglevel,omitempty"`
+	RateLimit  int           `yaml:"rateLimit,omitempty"` // max requests per minute per IP (0 = use default 100)
 	Lists      []MappingList `yaml:"lists,omitempty"`
 }
 
@@ -195,6 +197,7 @@
 		ServiceURL: globalConfig.ServiceURL,
 		Port:       globalConfig.Port,
 		LogLevel:   globalConfig.LogLevel,
+		RateLimit:  globalConfig.RateLimit,
 		Lists:      allLists,
 	}
 
@@ -227,6 +230,9 @@
 	if config.Port == 0 {
 		config.Port = defaultPort
 	}
+	if config.RateLimit == 0 {
+		config.RateLimit = defaultRateLimit
+	}
 }
 
 // ApplyEnvOverrides overrides configuration fields from environment variables.
@@ -253,6 +259,12 @@
 			config.Port = port
 		}
 	}
+
+	if val := os.Getenv("KORAL_MAPPER_RATE_LIMIT"); val != "" {
+		if rl, err := strconv.Atoi(val); err == nil {
+			config.RateLimit = rl
+		}
+	}
 }
 
 // validateMappingLists validates a slice of mapping lists (without duplicate ID checking)
diff --git a/config/config_test.go b/config/config_test.go
index 599191d..e2d02f3 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -1177,3 +1177,55 @@
 	assert.Equal(t, "textClass", and1.Operands[1].(*parser.CorpusField).Key)
 	assert.Equal(t, "musik", and1.Operands[1].(*parser.CorpusField).Value)
 }
+
+func TestRateLimitConfigField(t *testing.T) {
+	content := `
+rateLimit: 50
+lists:
+  - id: test-mapper
+    mappings:
+      - "[A] <> [B]"
+`
+	tmpfile, err := os.CreateTemp("", "config-ratelimit-*.yaml")
+	require.NoError(t, err)
+	defer os.Remove(tmpfile.Name())
+
+	_, err = tmpfile.WriteString(content)
+	require.NoError(t, err)
+	require.NoError(t, tmpfile.Close())
+
+	cfg, err := LoadFromSources(tmpfile.Name(), nil)
+	require.NoError(t, err)
+	assert.Equal(t, 50, cfg.RateLimit, "rateLimit should be loaded from YAML")
+}
+
+func TestRateLimitDefaultApplied(t *testing.T) {
+	cfg := &MappingConfig{}
+	ApplyDefaults(cfg)
+	assert.Equal(t, defaultRateLimit, cfg.RateLimit,
+		"default rate limit should be applied when not specified")
+}
+
+func TestRateLimitEnvOverride(t *testing.T) {
+	t.Setenv("KORAL_MAPPER_RATE_LIMIT", "200")
+
+	content := `
+rateLimit: 50
+lists:
+  - id: test-mapper
+    mappings:
+      - "[A] <> [B]"
+`
+	tmpfile, err := os.CreateTemp("", "config-ratelimit-env-*.yaml")
+	require.NoError(t, err)
+	defer os.Remove(tmpfile.Name())
+
+	_, err = tmpfile.WriteString(content)
+	require.NoError(t, err)
+	require.NoError(t, tmpfile.Close())
+
+	cfg, err := LoadFromSources(tmpfile.Name(), nil)
+	require.NoError(t, err)
+	assert.Equal(t, 200, cfg.RateLimit,
+		"KORAL_MAPPER_RATE_LIMIT env var should override YAML value")
+}
diff --git a/go.mod b/go.mod
index 3ba28bd..418112b 100644
--- a/go.mod
+++ b/go.mod
@@ -21,7 +21,9 @@
 	github.com/mattn/go-colorable v0.1.14 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-runewidth v0.0.20 // indirect
+	github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/tinylib/msgp v1.2.5 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/valyala/fasthttp v1.69.0 // indirect
 	golang.org/x/sys v0.41.0 // indirect
diff --git a/go.sum b/go.sum
index bae8893..cfb6e40 100644
--- a/go.sum
+++ b/go.sum
@@ -28,12 +28,16 @@
 github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
 github.com/orisano/gosax v1.1.4 h1:fJZ8180lWGOqck/unlYTo9bxjT4dcemG/NErUDcVOOw=
 github.com/orisano/gosax v1.1.4/go.mod h1:mw6A5jIOFDeVOqffQkggKOOjRFevYnLyXgiZP06fRjI=
+github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
+github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
 github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
+github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=