Update to fiber 3

Change-Id: I18a7b3ed55aef5cae67db6a613ec14e0f8a9281e
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fe6aea9..59fb3f0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,7 +14,7 @@
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        go-version: ['1.24']
+        go-version: ['1.26']
     steps:
       - uses: actions/checkout@v7
 
diff --git a/README.md b/README.md
index 2098ecc..f792a70 100644
--- a/README.md
+++ b/README.md
@@ -71,11 +71,12 @@
 # Optional: Maximum requests per minute per IP for rate limiting (default: 100)
 rateLimit: 100
 
-# Optional: Comma-separated list of allowed CORS origins.
+# Optional: 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"
+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.
@@ -124,7 +125,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).
+- **`allowOrigins`**: List of origins allowed for CORS (default: derived from `server` with trailing slash removed, e.g. `["https://korap.ids-mannheim.de"]`). Must be specified as a YAML list. 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).
 - **`rewrites`**: Global default for attaching `koral:rewrite` annotations (default: `false`). When `true`, all mapping lists will attach rewrite annotations unless individually overridden. See [Rewrites Resolution](#rewrites-resolution) for the full precedence chain.
 - **`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).
 
@@ -143,7 +144,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_ALLOW_ORIGINS`: Overrides `allowOrigins` (comma-separated string of allowed CORS origins, e.g. `https://a.com,https://b.com`)
 - `KORAL_MAPPER_REWRITES`: Overrides `rewrites` (`true` or `false`, global default for koral:rewrite annotations)
 - `KORAL_MAPPER_BASE_PATH`: Overrides `basePath` (directory path for file loading confinement)
 
diff --git a/cmd/koralmapper/fuzz_test.go b/cmd/koralmapper/fuzz_test.go
index 39f20c3..92c21e3 100644
--- a/cmd/koralmapper/fuzz_test.go
+++ b/cmd/koralmapper/fuzz_test.go
@@ -13,7 +13,7 @@
 
 	tmconfig "github.com/KorAP/Koral-Mapper/config"
 	"github.com/KorAP/Koral-Mapper/mapper"
-	"github.com/gofiber/fiber/v2"
+	"github.com/gofiber/fiber/v3"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -56,8 +56,7 @@
 
 	// Create fiber app
 	app := fiber.New(fiber.Config{
-		DisableStartupMessage: true,
-		ErrorHandler: func(c *fiber.Ctx, err error) error {
+		ErrorHandler: func(c fiber.Ctx, err error) error {
 			// For body limit errors, return 413 status code
 			if err.Error() == "body size exceeds the given limit" || errors.Is(err, fiber.ErrRequestEntityTooLarge) {
 				return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{
@@ -176,8 +175,7 @@
 
 	// Create fiber app
 	app := fiber.New(fiber.Config{
-		DisableStartupMessage: true,
-		ErrorHandler: func(c *fiber.Ctx, err error) error {
+		ErrorHandler: func(c fiber.Ctx, err error) error {
 			// For body limit errors, return 413 status code
 			if err.Error() == "body size exceeds the given limit" || errors.Is(err, fiber.ErrRequestEntityTooLarge) {
 				return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{
@@ -289,8 +287,7 @@
 
 	// Create fiber app
 	app := fiber.New(fiber.Config{
-		DisableStartupMessage: true,
-		ErrorHandler: func(c *fiber.Ctx, err error) error {
+		ErrorHandler: func(c fiber.Ctx, err error) error {
 			// For body limit errors, return 413 status code
 			if err.Error() == "body size exceeds the given limit" || errors.Is(err, fiber.ErrRequestEntityTooLarge) {
 				return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{
diff --git a/cmd/koralmapper/main.go b/cmd/koralmapper/main.go
index 66cd96b..ccea13b 100644
--- a/cmd/koralmapper/main.go
+++ b/cmd/koralmapper/main.go
@@ -19,9 +19,9 @@
 	"github.com/KorAP/Koral-Mapper/config"
 	"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/gofiber/fiber/v3"
+	"github.com/gofiber/fiber/v3/middleware/cors"
+	"github.com/gofiber/fiber/v3/middleware/limiter"
 	"github.com/rs/zerolog"
 	"github.com/rs/zerolog/log"
 )
@@ -140,12 +140,12 @@
 
 	// Only enable HTTP request logging if log level is debug or info
 	if currentLevel > zerolog.InfoLevel {
-		return func(c *fiber.Ctx) error {
+		return func(c fiber.Ctx) error {
 			return c.Next()
 		}
 	}
 
-	return func(c *fiber.Ctx) error {
+	return func(c fiber.Ctx) error {
 		// Record start time
 		start := time.Now()
 
@@ -179,9 +179,14 @@
 }
 
 // extractRequestParams extracts and validates common request parameters
-func extractRequestParams(c *fiber.Ctx) (*requestParams, error) {
+func extractRequestParams(c fiber.Ctx) (*requestParams, error) {
+	mapID, err := url.PathUnescape(c.Params("map"))
+	if err != nil {
+		return nil, fmt.Errorf("mapID contains invalid characters")
+	}
+
 	params := &requestParams{
-		MapID:    c.Params("map"),
+		MapID:    mapID,
 		Dir:      c.Query("dir", "atob"),
 		FoundryA: c.Query("foundryA", ""),
 		FoundryB: c.Query("foundryB", ""),
@@ -208,9 +213,9 @@
 }
 
 // parseRequestBody parses JSON request body and direction
-func parseRequestBody(c *fiber.Ctx, dir string) (any, mapper.Direction, error) {
+func parseRequestBody(c fiber.Ctx, dir string) (any, mapper.Direction, error) {
 	var jsonData any
-	if err := c.BodyParser(&jsonData); err != nil {
+	if err := c.Bind().Body(&jsonData); err != nil {
 		return nil, mapper.BtoA, fmt.Errorf("invalid JSON in request body")
 	}
 
@@ -280,10 +285,9 @@
 
 	// Create fiber app
 	app := fiber.New(fiber.Config{
-		DisableStartupMessage: true,
-		BodyLimit:             maxInputLength,
-		ReadBufferSize:        64 * 1024, // 64KB - increase header size limit
-		WriteBufferSize:       64 * 1024, // 64KB - increase response buffer size
+		BodyLimit:       maxInputLength,
+		ReadBufferSize:  64 * 1024, // 64KB - increase header size limit
+		WriteBufferSize: 64 * 1024, // 64KB - increase response buffer size,
 	})
 
 	// Add zerolog-integrated logger middleware
@@ -305,7 +309,7 @@
 			)
 		}
 
-		if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
+		if err := app.Listen(fmt.Sprintf(":%d", finalPort), fiber.ListenConfig{DisableStartupMessage: true}); err != nil {
 			log.Fatal().Err(err).Msg("Server error")
 		}
 	}()
@@ -330,7 +334,7 @@
 	// 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 {
+	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()
@@ -344,8 +348,8 @@
 	// (default: "https://korap.ids-mannheim.de").
 	app.Use(cors.New(cors.Config{
 		AllowOrigins: yamlConfig.AllowOrigins,
-		AllowMethods: "GET,POST",
-		AllowHeaders: "Content-Type",
+		AllowMethods: []string{"GET", "POST"},
+		AllowHeaders: []string{"Content-Type"},
 	}))
 
 	// Rate limiting middleware to prevent resource exhaustion from
@@ -363,7 +367,7 @@
 	}))
 
 	// Health check endpoint
-	app.Get("/health", func(c *fiber.Ctx) error {
+	app.Get("/health", func(c fiber.Ctx) error {
 		return c.SendString("OK")
 	})
 
@@ -386,7 +390,7 @@
 }
 
 func handleStaticFile() fiber.Handler {
-	return func(c *fiber.Ctx) error {
+	return func(c fiber.Ctx) error {
 		name := c.Params("*")
 		data, err := fs.ReadFile(staticFS, "static/"+name)
 		if err != nil {
@@ -468,7 +472,7 @@
 		listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
 	}
 
-	return func(c *fiber.Ctx) error {
+	return func(c fiber.Ctx) error {
 		cfgRaw := c.Params("cfg")
 		if len(cfgRaw) > maxParamLength {
 			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
@@ -477,7 +481,7 @@
 		}
 
 		var jsonData any
-		if err := c.BodyParser(&jsonData); err != nil {
+		if err := c.Bind().Body(&jsonData); err != nil {
 			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
 				"error": "invalid JSON in request body",
 			})
@@ -548,7 +552,7 @@
 		listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
 	}
 
-	return func(c *fiber.Ctx) error {
+	return func(c fiber.Ctx) error {
 		cfgRaw := c.Params("cfg")
 		if len(cfgRaw) > maxParamLength {
 			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
@@ -557,7 +561,7 @@
 		}
 
 		var jsonData any
-		if err := c.BodyParser(&jsonData); err != nil {
+		if err := c.Bind().Body(&jsonData); err != nil {
 			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
 				"error": "invalid JSON in request body",
 			})
@@ -628,7 +632,7 @@
 		listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
 	}
 
-	return func(c *fiber.Ctx) error {
+	return func(c fiber.Ctx) error {
 		// Extract and validate parameters
 		params, err := extractRequestParams(c)
 		if err != nil {
@@ -685,7 +689,7 @@
 		listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
 	}
 
-	return func(c *fiber.Ctx) error {
+	return func(c fiber.Ctx) error {
 		// Extract and validate parameters
 		params, err := extractRequestParams(c)
 		if err != nil {
@@ -769,8 +773,8 @@
 }
 
 func handleKalamarPlugin(yamlConfig *config.MappingConfig, configTmpl *template.Template, pluginTmpl *template.Template) fiber.Handler {
-	return func(c *fiber.Ctx) error {
-		mapID := c.Params("map")
+	return func(c fiber.Ctx) error {
+		mapID, _ := url.PathUnescape(c.Params("map"))
 
 		// Config page (GET /)
 		if mapID == "" {
diff --git a/cmd/koralmapper/main_test.go b/cmd/koralmapper/main_test.go
index d0d77f2..475d0dd 100644
--- a/cmd/koralmapper/main_test.go
+++ b/cmd/koralmapper/main_test.go
@@ -16,7 +16,7 @@
 
 	tmconfig "github.com/KorAP/Koral-Mapper/config"
 	"github.com/KorAP/Koral-Mapper/mapper"
-	"github.com/gofiber/fiber/v2"
+	"github.com/gofiber/fiber/v3"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -2797,7 +2797,7 @@
 	require.NoError(t, err)
 
 	mockConfig := &tmconfig.MappingConfig{
-		AllowOrigins: "https://custom.example.com",
+		AllowOrigins: []string{"https://custom.example.com"},
 		Lists:        []tmconfig.MappingList{mappingList},
 	}
 	tmconfig.ApplyDefaults(mockConfig)
@@ -2830,7 +2830,7 @@
 	require.NoError(t, err)
 
 	mockConfig := &tmconfig.MappingConfig{
-		AllowOrigins: "https://allowed.example.com",
+		AllowOrigins: []string{"https://allowed.example.com"},
 		Lists:        []tmconfig.MappingList{mappingList},
 	}
 	tmconfig.ApplyDefaults(mockConfig)
@@ -2860,7 +2860,7 @@
 	require.NoError(t, err)
 
 	mockConfig := &tmconfig.MappingConfig{
-		AllowOrigins: "https://first.example.com,https://second.example.com",
+		AllowOrigins: []string{"https://first.example.com", "https://second.example.com"},
 		Lists:        []tmconfig.MappingList{mappingList},
 	}
 	tmconfig.ApplyDefaults(mockConfig)
diff --git a/config/config.go b/config/config.go
index 9347994..e024c76 100644
--- a/config/config.go
+++ b/config/config.go
@@ -109,7 +109,7 @@
 	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
+	AllowOrigins []string      `yaml:"allowOrigins,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)
@@ -117,6 +117,28 @@
 	Lists        []MappingList `yaml:"lists,omitempty"`
 }
 
+// UnmarshalYAML rejects the deprecated comma-separated string format for
+// allowOrigins and requires a YAML list instead.
+func (m *MappingConfig) UnmarshalYAML(value *yaml.Node) error {
+	if value.Kind == yaml.MappingNode {
+		for i := 0; i < len(value.Content)-1; i += 2 {
+			if value.Content[i].Value == "allowOrigins" && value.Content[i+1].Kind == yaml.ScalarNode {
+				return fmt.Errorf(
+					"allowOrigins must be a YAML list, not a comma-separated string; update your config:\n" +
+						"  allowOrigins:\n" +
+						"    - \"https://example.com\"")
+			}
+		}
+	}
+	type plain MappingConfig
+	var p plain
+	if err := value.Decode(&p); err != nil {
+		return err
+	}
+	*m = MappingConfig(p)
+	return nil
+}
+
 // AllowedBasePath restricts file loading to a specific directory tree.
 // When set, all file paths must resolve to a location at or below this
 // directory (or under the system temp directory). Defaults to the CWD at
@@ -206,6 +228,8 @@
 				seenIDs[list.ID] = true
 			}
 			allLists = append(allLists, globalConfig.Lists...)
+		} else if strings.Contains(err.Error(), "allowOrigins must be") {
+			return nil, fmt.Errorf("failed to parse config file '%s': %w", configFile, err)
 		} else {
 			// Fall back to old format (direct list)
 			var lists []MappingList
@@ -309,8 +333,8 @@
 
 	// AllowOrigins defaults to the Server value. This avoids duplicating
 	// the server URL string and keeps CORS in sync with the deployment.
-	if config.AllowOrigins == "" {
-		config.AllowOrigins = config.Server
+	if len(config.AllowOrigins) == 0 {
+		config.AllowOrigins = []string{config.Server}
 	}
 	config.AllowOrigins = normalizeOrigins(config.AllowOrigins)
 
@@ -322,21 +346,24 @@
 	}
 }
 
-// normalizeOrigins takes a comma-separated list of origin URLs and strips
-// any path components, returning only scheme + host (+ port when present).
-// The CORS middleware requires bare origins without paths; URLs like
+// normalizeOrigins strips path components from origin URLs, returning only
+// scheme + host (+ port when present). The CORS middleware requires bare
+// origins without paths; URLs like
 // "https://example.com/instance/test" are pruned to "https://example.com".
-func normalizeOrigins(raw string) string {
-	parts := strings.Split(raw, ",")
-	for i, part := range parts {
-		part = strings.TrimSpace(part)
-		if u, err := url.Parse(part); err == nil && u.Host != "" {
-			parts[i] = u.Scheme + "://" + u.Host
+func normalizeOrigins(origins []string) []string {
+	result := make([]string, 0, len(origins))
+	for _, origin := range origins {
+		origin = strings.TrimSpace(origin)
+		if origin == "" {
+			continue
+		}
+		if u, err := url.Parse(origin); err == nil && u.Host != "" {
+			result = append(result, u.Scheme+"://"+u.Host)
 		} else {
-			parts[i] = strings.TrimRight(part, "/")
+			result = append(result, strings.TrimRight(origin, "/"))
 		}
 	}
-	return strings.Join(parts, ",")
+	return result
 }
 
 // ApplyEnvOverrides overrides configuration fields from environment variables.
@@ -344,14 +371,13 @@
 // 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_ALLOW_ORIGINS": &config.AllowOrigins,
+		"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,
 	}
 
 	for envKey, field := range envMappings {
@@ -360,6 +386,10 @@
 		}
 	}
 
+	if val := os.Getenv("KORAL_MAPPER_ALLOW_ORIGINS"); val != "" {
+		config.AllowOrigins = strings.Split(val, ",")
+	}
+
 	if val := os.Getenv("KORAL_MAPPER_PORT"); val != "" {
 		if port, err := strconv.Atoi(val); err == nil {
 			config.Port = port
diff --git a/config/config_test.go b/config/config_test.go
index a7d121f..d8c7789 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -1191,31 +1191,31 @@
 
 	tests := []struct {
 		name          string
-		listRewrites *bool
+		listRewrites  *bool
 		globalDefault bool
 		expected      bool
 	}{
 		{
 			name:          "nil per-list, global false",
-			listRewrites: nil,
+			listRewrites:  nil,
 			globalDefault: false,
 			expected:      false,
 		},
 		{
 			name:          "nil per-list, global true",
-			listRewrites: nil,
+			listRewrites:  nil,
 			globalDefault: true,
 			expected:      true,
 		},
 		{
 			name:          "per-list true, global false",
-			listRewrites: &trueVal,
+			listRewrites:  &trueVal,
 			globalDefault: false,
 			expected:      true,
 		},
 		{
 			name:          "per-list false, global true",
-			listRewrites: &falseVal,
+			listRewrites:  &falseVal,
 			globalDefault: true,
 			expected:      false,
 		},
@@ -1425,7 +1425,7 @@
 	cfg := &MappingConfig{}
 	ApplyDefaults(cfg)
 	// AllowOrigins should derive from the Server default (trailing slash stripped)
-	assert.Equal(t, "https://korap.ids-mannheim.de", cfg.AllowOrigins,
+	assert.Equal(t, []string{"https://korap.ids-mannheim.de"}, cfg.AllowOrigins,
 		"default AllowOrigins should derive from defaultServer")
 }
 
@@ -1434,23 +1434,25 @@
 		Server: "https://custom.example.com/",
 	}
 	ApplyDefaults(cfg)
-	assert.Equal(t, "https://custom.example.com", cfg.AllowOrigins,
+	assert.Equal(t, []string{"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",
+		AllowOrigins: []string{"https://explicit-origin.example.com"},
 	}
 	ApplyDefaults(cfg)
-	assert.Equal(t, "https://explicit-origin.example.com", cfg.AllowOrigins,
+	assert.Equal(t, []string{"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"
+allowOrigins:
+  - "https://custom.example.com"
+  - "https://other.example.com"
 lists:
   - id: test-mapper
     mappings:
@@ -1466,15 +1468,37 @@
 
 	cfg, err := LoadFromSources(tmpfile.Name(), nil)
 	require.NoError(t, err)
-	assert.Equal(t, "https://custom.example.com,https://other.example.com",
+	assert.Equal(t, []string{"https://custom.example.com", "https://other.example.com"},
 		cfg.AllowOrigins)
 }
 
+func TestAllowOriginsStringFormatRejected(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-reject-*.yaml")
+	require.NoError(t, err)
+	defer os.Remove(tmpfile.Name())
+
+	_, err = tmpfile.WriteString(content)
+	require.NoError(t, err)
+	require.NoError(t, tmpfile.Close())
+
+	_, err = LoadFromSources(tmpfile.Name(), nil)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "allowOrigins must be a YAML list")
+}
+
 func TestAllowOriginsEnvOverride(t *testing.T) {
 	t.Setenv("KORAL_MAPPER_ALLOW_ORIGINS", "https://env-origin.example.com")
 
 	content := `
-allowOrigins: "https://yaml-origin.example.com"
+allowOrigins:
+  - "https://yaml-origin.example.com"
 lists:
   - id: test-mapper
     mappings:
@@ -1490,7 +1514,7 @@
 
 	cfg, err := LoadFromSources(tmpfile.Name(), nil)
 	require.NoError(t, err)
-	assert.Equal(t, "https://env-origin.example.com", cfg.AllowOrigins,
+	assert.Equal(t, []string{"https://env-origin.example.com"}, cfg.AllowOrigins,
 		"KORAL_MAPPER_ALLOW_ORIGINS env var should override YAML value")
 }
 
@@ -1499,16 +1523,16 @@
 		Server: "https://korap.ids-mannheim.de/instance/test",
 	}
 	ApplyDefaults(cfg)
-	assert.Equal(t, "https://korap.ids-mannheim.de", cfg.AllowOrigins,
+	assert.Equal(t, []string{"https://korap.ids-mannheim.de"}, cfg.AllowOrigins,
 		"AllowOrigins should be pruned to host-level origin when Server contains a path")
 }
 
 func TestAllowOriginsExplicitWithPathsPruned(t *testing.T) {
 	cfg := &MappingConfig{
-		AllowOrigins: "https://korap.ids-mannheim.de/instance/test,https://other.example.com/app",
+		AllowOrigins: []string{"https://korap.ids-mannheim.de/instance/test", "https://other.example.com/app"},
 	}
 	ApplyDefaults(cfg)
-	assert.Equal(t, "https://korap.ids-mannheim.de,https://other.example.com", cfg.AllowOrigins,
+	assert.Equal(t, []string{"https://korap.ids-mannheim.de", "https://other.example.com"}, cfg.AllowOrigins,
 		"explicit AllowOrigins entries should be pruned to host-level origins")
 }
 
@@ -1517,7 +1541,7 @@
 		Server: "https://korap.ids-mannheim.de:8080/instance/test",
 	}
 	ApplyDefaults(cfg)
-	assert.Equal(t, "https://korap.ids-mannheim.de:8080", cfg.AllowOrigins,
+	assert.Equal(t, []string{"https://korap.ids-mannheim.de:8080"}, cfg.AllowOrigins,
 		"AllowOrigins should preserve port but strip path")
 }
 
diff --git a/go.mod b/go.mod
index 0a0097b..321de65 100644
--- a/go.mod
+++ b/go.mod
@@ -1,11 +1,11 @@
 module github.com/KorAP/Koral-Mapper
 
-go 1.25.0
+go 1.26.0
 
 require (
 	github.com/alecthomas/kong v1.15.0
 	github.com/alecthomas/participle/v2 v2.1.4
-	github.com/gofiber/fiber/v2 v2.52.14
+	github.com/gofiber/fiber/v3 v3.4.0
 	github.com/orisano/gosax v1.1.4
 	github.com/rs/zerolog v1.35.1
 	github.com/stretchr/testify v1.11.1
@@ -14,17 +14,20 @@
 
 require (
 	github.com/andybalholm/brotli v1.2.2 // indirect
-	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/gofiber/schema v1.8.0 // indirect
+	github.com/gofiber/utils/v2 v2.1.1 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/klauspost/compress v1.19.0 // indirect
 	github.com/mattn/go-colorable v0.1.15 // indirect
 	github.com/mattn/go-isatty v0.0.22 // indirect
-	github.com/mattn/go-runewidth v0.0.24 // indirect
 	github.com/philhofer/fwd v1.2.0 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/tinylib/msgp v1.6.4 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
 	github.com/valyala/fasthttp v1.72.0 // indirect
+	golang.org/x/crypto v0.53.0 // indirect
+	golang.org/x/net v0.56.0 // indirect
 	golang.org/x/sys v0.47.0 // indirect
+	golang.org/x/text v0.38.0 // indirect
 )
diff --git a/go.sum b/go.sum
index 5a0ba81..65bfb0e 100644
--- a/go.sum
+++ b/go.sum
@@ -8,12 +8,16 @@
 github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
 github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
-github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
-github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/gofiber/fiber/v2 v2.52.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE=
-github.com/gofiber/fiber/v2 v2.52.14/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
+github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
+github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/gofiber/fiber/v3 v3.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw=
+github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk=
+github.com/gofiber/schema v1.8.0 h1:NGsC9toPHmj8Xg4KpznuXBzNmHG6V5YV0tXKpKMcmis=
+github.com/gofiber/schema v1.8.0/go.mod h1:lmbXPQ8hvzXSLkdS2DS7pb4kpunC2Roh7Sj3HMjGfzA=
+github.com/gofiber/utils/v2 v2.1.1 h1:kGnoGjwEnFW6w0x45W+kLlmMJvqBGkuUA4oMWKn/T/I=
+github.com/gofiber/utils/v2 v2.1.1/go.mod h1:DdOgEVwQTi8cou/AKWPqhXOR4fHGRVhA/rEWL3IXG7Q=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -24,8 +28,6 @@
 github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
 github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
 github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
-github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
-github.com/mattn/go-runewidth v0.0.24/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.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
@@ -34,6 +36,8 @@
 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/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw=
+github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
 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.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
@@ -42,10 +46,18 @@
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
 github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=