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=