Add configurable CORS
Change-Id: I6b902e028c1f192987b4d7d6415aad456137c520
diff --git a/README.md b/README.md
index 780f3bd..aa1fe08 100644
--- a/README.md
+++ b/README.md
@@ -65,6 +65,12 @@
# Optional: Maximum requests per minute per IP for rate limiting (default: 100)
rateLimit: 100
+# Optional: Comma-separated list of allowed CORS origins.
+# Defaults to the server value (trailing slash stripped).
+# Required when the service is called cross-origin (e.g. as a Kalamar plugin in an iframe).
+# Use "*" to allow all origins (not recommended for production).
+allowOrigins: "https://korap.ids-mannheim.de"
+
# Optional: Base path for file loading confinement (default: current working directory).
# All config and mapping file paths must resolve within this directory or /tmp.
# Set to "/" to allow loading from anywhere on the filesystem.
@@ -108,6 +114,7 @@
- **`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).
+- **`allowOrigins`**: Comma-separated list of origins allowed for CORS (default: derived from `server` with trailing slash removed, e.g. `https://korap.ids-mannheim.de`). The service is designed to be called cross-origin as a Kalamar plugin loaded in iframes. This setting controls which origins may make cross-origin API requests. Allowed methods are `GET` and `POST`. The `Content-Type` header is permitted. Use `"*"` to allow all origins (not recommended for production).
- **`basePath`**: Directory tree for file loading confinement (default: current working directory). Configuration and mapping files must resolve within this path or the system temp directory. Set to `"/"` to disable confinement. This prevents path traversal attacks (CWE-22).
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.
@@ -125,6 +132,7 @@
- `KORAL_MAPPER_LOG_LEVEL`: Overrides `loglevel`
- `KORAL_MAPPER_PORT`: Overrides `port` (integer)
- `KORAL_MAPPER_RATE_LIMIT`: Overrides `rateLimit` (integer, requests per minute per IP)
+- `KORAL_MAPPER_ALLOW_ORIGINS`: Overrides `allowOrigins` (comma-separated list of allowed CORS origins)
- `KORAL_MAPPER_BASE_PATH`: Overrides `basePath` (directory path for file loading confinement)
Environment variable values take precedence over values from the configuration file.
diff --git a/cmd/koralmapper/main.go b/cmd/koralmapper/main.go
index eac2984..50bdace 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/cors"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@@ -335,6 +336,18 @@
return c.Next()
})
+ // CORS middleware to allow cross-origin requests from trusted
+ // origins. Required because the service is designed to be
+ // called as a KorAP/Kalamar plugin from cross-origin iframes.
+ // Configurable via the "allowOrigins" YAML key or the
+ // KORAL_MAPPER_ALLOW_ORIGINS environment variable
+ // (default: "https://korap.ids-mannheim.de").
+ app.Use(cors.New(cors.Config{
+ AllowOrigins: yamlConfig.AllowOrigins,
+ AllowMethods: "GET,POST",
+ AllowHeaders: "Content-Type",
+ }))
+
// 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
diff --git a/cmd/koralmapper/main_test.go b/cmd/koralmapper/main_test.go
index 92318a3..fbabbb7 100644
--- a/cmd/koralmapper/main_test.go
+++ b/cmd/koralmapper/main_test.go
@@ -2742,3 +2742,251 @@
assert.Greater(t, idxA, idxZ, "mapper-a should appear after mapper-z")
assert.Greater(t, idxM, idxA, "mapper-m should appear after mapper-a")
}
+
+// TestCORSHeadersDefault verifies that CORS headers are present with
+// the default AllowOrigins value when no custom origin is configured.
+func TestCORSHeadersDefault(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)
+
+ // Preflight OPTIONS request with default allowed origin
+ req := httptest.NewRequest(http.MethodOptions, "/health", nil)
+ req.Header.Set("Origin", "https://korap.ids-mannheim.de")
+ req.Header.Set("Access-Control-Request-Method", "GET")
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, "https://korap.ids-mannheim.de",
+ resp.Header.Get("Access-Control-Allow-Origin"),
+ "default AllowOrigins should include korap.ids-mannheim.de")
+ assert.NotEmpty(t, resp.Header.Get("Access-Control-Allow-Methods"))
+
+ // Actual GET request should include CORS headers too
+ req = httptest.NewRequest(http.MethodGet, "/health", nil)
+ req.Header.Set("Origin", "https://korap.ids-mannheim.de")
+ resp, err = app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.Equal(t, "https://korap.ids-mannheim.de",
+ resp.Header.Get("Access-Control-Allow-Origin"))
+}
+
+// TestCORSHeadersCustomOrigin verifies that a custom AllowOrigins value
+// is propagated to CORS response headers.
+func TestCORSHeadersCustomOrigin(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{
+ AllowOrigins: "https://custom.example.com",
+ Lists: []tmconfig.MappingList{mappingList},
+ }
+ tmconfig.ApplyDefaults(mockConfig)
+
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ req := httptest.NewRequest(http.MethodGet, "/health", nil)
+ req.Header.Set("Origin", "https://custom.example.com")
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.Equal(t, "https://custom.example.com",
+ resp.Header.Get("Access-Control-Allow-Origin"),
+ "custom AllowOrigins should be reflected in the response")
+}
+
+// TestCORSRejectsDisallowedOrigin verifies that requests from origins
+// not listed in AllowOrigins do not receive an Access-Control-Allow-Origin
+// header.
+func TestCORSRejectsDisallowedOrigin(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{
+ AllowOrigins: "https://allowed.example.com",
+ Lists: []tmconfig.MappingList{mappingList},
+ }
+ tmconfig.ApplyDefaults(mockConfig)
+
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ req := httptest.NewRequest(http.MethodGet, "/health", nil)
+ req.Header.Set("Origin", "https://evil.example.com")
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Empty(t, resp.Header.Get("Access-Control-Allow-Origin"),
+ "disallowed origin must not receive Access-Control-Allow-Origin")
+}
+
+// TestCORSAllowsMultipleOrigins verifies that multiple comma-separated
+// origins are all accepted by the CORS middleware.
+func TestCORSAllowsMultipleOrigins(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{
+ AllowOrigins: "https://first.example.com,https://second.example.com",
+ Lists: []tmconfig.MappingList{mappingList},
+ }
+ tmconfig.ApplyDefaults(mockConfig)
+
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ for _, origin := range []string{
+ "https://first.example.com",
+ "https://second.example.com",
+ } {
+ t.Run(origin, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/health", nil)
+ req.Header.Set("Origin", origin)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.Equal(t, origin,
+ resp.Header.Get("Access-Control-Allow-Origin"),
+ "both configured origins should be accepted")
+ })
+ }
+}
+
+// TestCORSPreflightAllowedMethods verifies that the CORS preflight
+// response advertises only GET and POST methods by default.
+func TestCORSPreflightAllowedMethods(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)
+
+ req := httptest.NewRequest(http.MethodOptions, "/test-mapper/query", nil)
+ req.Header.Set("Origin", "https://korap.ids-mannheim.de")
+ req.Header.Set("Access-Control-Request-Method", "POST")
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ allowedMethods := resp.Header.Get("Access-Control-Allow-Methods")
+ assert.Contains(t, allowedMethods, "GET")
+ assert.Contains(t, allowedMethods, "POST")
+}
+
+// TestCORSPreflightAllowedHeaders verifies that the Content-Type
+// header is allowed in CORS preflight responses.
+func TestCORSPreflightAllowedHeaders(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)
+
+ req := httptest.NewRequest(http.MethodOptions, "/test-mapper/query", nil)
+ req.Header.Set("Origin", "https://korap.ids-mannheim.de")
+ req.Header.Set("Access-Control-Request-Method", "POST")
+ req.Header.Set("Access-Control-Request-Headers", "Content-Type")
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ allowedHeaders := resp.Header.Get("Access-Control-Allow-Headers")
+ assert.Contains(t, allowedHeaders, "Content-Type")
+}
+
+// TestCORSOnTransformEndpoint verifies CORS headers are present on
+// actual POST requests to the transformation endpoints (not just
+// health check).
+func TestCORSOnTransformEndpoint(t *testing.T) {
+ cfg := loadConfigFromYAML(t, `
+lists:
+ - id: test-mapper
+ foundryA: opennlp
+ layerA: p
+ foundryB: upos
+ layerB: p
+ mappings:
+ - "[PIDAT] <> [DET]"
+`)
+
+ m, err := mapper.NewMapper(cfg.Lists)
+ require.NoError(t, err)
+
+ app := fiber.New()
+ setupRoutes(app, m, cfg)
+
+ input := `{
+ "@type": "koral:token",
+ "wrap": {
+ "@type": "koral:term",
+ "foundry": "opennlp",
+ "key": "PIDAT",
+ "layer": "p",
+ "match": "match:eq"
+ }
+ }`
+
+ req := httptest.NewRequest(http.MethodPost, "/test-mapper/query?dir=atob",
+ bytes.NewBufferString(input))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Origin", "https://korap.ids-mannheim.de")
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.Equal(t, "https://korap.ids-mannheim.de",
+ resp.Header.Get("Access-Control-Allow-Origin"),
+ "transform endpoints should include CORS headers")
+}
diff --git a/config/config.go b/config/config.go
index 7fa600e..6c4c9bf 100644
--- a/config/config.go
+++ b/config/config.go
@@ -20,8 +20,8 @@
defaultServiceURL = "https://korap.ids-mannheim.de/plugin/koralmapper"
defaultCookieName = "km-config"
defaultPort = 5725
- defaultLogLevel = "warn"
- defaultRateLimit = 100
+ defaultLogLevel = "warn"
+ defaultRateLimit = 100
)
// MappingRule represents a single mapping rule in the configuration
@@ -92,16 +92,17 @@
// MappingConfig represents the root configuration containing multiple mapping lists
type MappingConfig struct {
- SDK string `yaml:"sdk,omitempty"`
- Stylesheet string `yaml:"stylesheet,omitempty"`
- Server string `yaml:"server,omitempty"`
- ServiceURL string `yaml:"serviceURL,omitempty"`
- CookieName string `yaml:"cookieName,omitempty"`
- BasePath string `yaml:"basePath,omitempty"` // restricts config file loading to this directory tree
- 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"`
+ SDK string `yaml:"sdk,omitempty"`
+ Stylesheet string `yaml:"stylesheet,omitempty"`
+ Server string `yaml:"server,omitempty"`
+ ServiceURL string `yaml:"serviceURL,omitempty"`
+ CookieName string `yaml:"cookieName,omitempty"`
+ BasePath string `yaml:"basePath,omitempty"` // restricts config file loading to this directory tree
+ AllowOrigins string `yaml:"allowOrigins,omitempty"` // comma-separated list of allowed CORS origins
+ 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"`
}
// AllowedBasePath restricts file loading to a specific directory tree.
@@ -255,15 +256,16 @@
// Create final configuration
result := &MappingConfig{
- SDK: globalConfig.SDK,
- Stylesheet: globalConfig.Stylesheet,
- Server: globalConfig.Server,
- ServiceURL: globalConfig.ServiceURL,
- BasePath: globalConfig.BasePath,
- Port: globalConfig.Port,
- LogLevel: globalConfig.LogLevel,
- RateLimit: globalConfig.RateLimit,
- Lists: allLists,
+ SDK: globalConfig.SDK,
+ Stylesheet: globalConfig.Stylesheet,
+ Server: globalConfig.Server,
+ ServiceURL: globalConfig.ServiceURL,
+ BasePath: globalConfig.BasePath,
+ AllowOrigins: globalConfig.AllowOrigins,
+ Port: globalConfig.Port,
+ LogLevel: globalConfig.LogLevel,
+ RateLimit: globalConfig.RateLimit,
+ Lists: allLists,
}
// Apply environment variable overrides (ENV > config file)
@@ -292,6 +294,13 @@
}
}
+ // AllowOrigins defaults to the Server value (with trailing slash
+ // stripped to form a proper origin). This avoids duplicating the
+ // server URL string and keeps CORS in sync with the deployment.
+ if config.AllowOrigins == "" {
+ config.AllowOrigins = strings.TrimRight(config.Server, "/")
+ }
+
if config.Port == 0 {
config.Port = defaultPort
}
@@ -305,13 +314,14 @@
// Non-empty environment values override any previously loaded config values.
func ApplyEnvOverrides(config *MappingConfig) {
envMappings := map[string]*string{
- "KORAL_MAPPER_SERVER": &config.Server,
- "KORAL_MAPPER_SDK": &config.SDK,
- "KORAL_MAPPER_STYLESHEET": &config.Stylesheet,
- "KORAL_MAPPER_SERVICE_URL": &config.ServiceURL,
- "KORAL_MAPPER_COOKIE_NAME": &config.CookieName,
- "KORAL_MAPPER_LOG_LEVEL": &config.LogLevel,
- "KORAL_MAPPER_BASE_PATH": &config.BasePath,
+ "KORAL_MAPPER_SERVER": &config.Server,
+ "KORAL_MAPPER_SDK": &config.SDK,
+ "KORAL_MAPPER_STYLESHEET": &config.Stylesheet,
+ "KORAL_MAPPER_SERVICE_URL": &config.ServiceURL,
+ "KORAL_MAPPER_COOKIE_NAME": &config.CookieName,
+ "KORAL_MAPPER_LOG_LEVEL": &config.LogLevel,
+ "KORAL_MAPPER_BASE_PATH": &config.BasePath,
+ "KORAL_MAPPER_ALLOW_ORIGINS": &config.AllowOrigins,
}
for envKey, field := range envMappings {
diff --git a/config/config_test.go b/config/config_test.go
index 67aa821..bac6df0 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -961,6 +961,7 @@
"KORAL_MAPPER_COOKIE_NAME",
"KORAL_MAPPER_PORT",
"KORAL_MAPPER_LOG_LEVEL",
+ "KORAL_MAPPER_ALLOW_ORIGINS",
}
clearEnv := func() {
@@ -1101,6 +1102,7 @@
"KORAL_MAPPER_STYLESHEET",
"KORAL_MAPPER_SERVICE_URL",
"KORAL_MAPPER_COOKIE_NAME",
+ "KORAL_MAPPER_ALLOW_ORIGINS",
}
clearEnv := func() {
for _, key := range envKeys {
@@ -1261,6 +1263,79 @@
"KORAL_MAPPER_RATE_LIMIT env var should override YAML value")
}
+func TestAllowOriginsDefault(t *testing.T) {
+ cfg := &MappingConfig{}
+ ApplyDefaults(cfg)
+ // AllowOrigins should derive from the Server default (trailing slash stripped)
+ assert.Equal(t, "https://korap.ids-mannheim.de", cfg.AllowOrigins,
+ "default AllowOrigins should derive from defaultServer")
+}
+
+func TestAllowOriginsDerivedFromCustomServer(t *testing.T) {
+ cfg := &MappingConfig{
+ Server: "https://custom.example.com/",
+ }
+ ApplyDefaults(cfg)
+ assert.Equal(t, "https://custom.example.com", cfg.AllowOrigins,
+ "AllowOrigins should derive from the configured Server (trailing slash stripped)")
+}
+
+func TestAllowOriginsExplicitNotOverriddenByServer(t *testing.T) {
+ cfg := &MappingConfig{
+ Server: "https://custom.example.com/",
+ AllowOrigins: "https://explicit-origin.example.com",
+ }
+ ApplyDefaults(cfg)
+ assert.Equal(t, "https://explicit-origin.example.com", cfg.AllowOrigins,
+ "explicit AllowOrigins should not be overridden by Server default")
+}
+
+func TestAllowOriginsFromYAML(t *testing.T) {
+ content := `
+allowOrigins: "https://custom.example.com,https://other.example.com"
+lists:
+ - id: test-mapper
+ mappings:
+ - "[A] <> [B]"
+`
+ tmpfile, err := os.CreateTemp("", "config-cors-*.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, "https://custom.example.com,https://other.example.com",
+ cfg.AllowOrigins)
+}
+
+func TestAllowOriginsEnvOverride(t *testing.T) {
+ t.Setenv("KORAL_MAPPER_ALLOW_ORIGINS", "https://env-origin.example.com")
+
+ content := `
+allowOrigins: "https://yaml-origin.example.com"
+lists:
+ - id: test-mapper
+ mappings:
+ - "[A] <> [B]"
+`
+ tmpfile, err := os.CreateTemp("", "config-cors-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, "https://env-origin.example.com", cfg.AllowOrigins,
+ "KORAL_MAPPER_ALLOW_ORIGINS env var should override YAML value")
+}
+
func TestSanitizeFilePathRejectsOutsideBase(t *testing.T) {
// Set base to a specific directory and verify paths outside are rejected
tmpDir, err := os.MkdirTemp("", "koral-base-*")