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-*")