Add security headers and configurable rate limiting middleware

Change-Id: Id976e75e58d79ea38e7939afe7576a2295a1c5af
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")
+}