Parcourir la source

webapi: replace custom CORS implementation with library

- make allowed CORS origins configurable
- reorganize routes
Mike il y a 1 mois
Parent
commit
915535b673
11 fichiers modifiés avec 684 ajouts et 333 suppressions
  1. 3 8
      Makefile
  2. 1 0
      cmd/server/factory.go
  3. 60 1
      config/config.go
  4. 258 0
      config/config_test.go
  5. 27 0
      config/settings.env
  6. 27 0
      config/ssl/settings.env
  7. 1 0
      go.mod
  8. 2 0
      go.sum
  9. 2 45
      server/webapi/middleware.go
  10. 201 119
      server/webapi/middleware_test.go
  11. 102 160
      server/webapi/server.go

+ 3 - 8
Makefile

@@ -31,14 +31,9 @@ NGINX_IMAGE ?= ras-nginx:1.28.0-openssl-1.0.2u
 # Host directory holding the web client that nginx serves.
 CLIENT_DIR ?= ./clients
 
-.PHONY: config-basic config-ssl config
-config-basic: ## Generate basic config file template
-	go run ./cmd/config_generator unix config/settings.env basic
-
-config-ssl: ## Generate SSL config file template
-	go run ./cmd/config_generator unix config/ssl/settings.env ssl
-
-config: config-basic config-ssl ## Generate all config file templates from Config struct
+.PHONY: config
+config: ## Generate all config file templates from Config struct
+	go generate ./config
 
 .PHONY: lint
 lint: ## Run formatting and static analysis checks

+ 1 - 0
cmd/server/factory.go

@@ -598,6 +598,7 @@ func WebAPI(deps Container) *webapi.Server {
 		IconSource:         iconSource,
 		BARTService:        bartService,
 		SNACRateLimits:     deps.snacRateLimits,
+		AllowedOrigins:     deps.cfg.WebAPIAllowedOrigins,
 	}
 
 	return webapi.NewServer(deps.cfg.WebAPIListeners, logger, handler, deps.webAPISessionManager)

+ 60 - 1
config/config.go

@@ -32,6 +32,52 @@ type Build struct {
 	Date    string `json:"date"`
 }
 
+func validateAllowedOrigin(origin string) error {
+	const format = "Valid format: SCHEME://HOST[:PORT] with at most one * (e.g., https://example.com, http://localhost:*)"
+
+	reject := func(reason string) error {
+		return fmt.Errorf("invalid web API allowed origin %q: %s. %s", origin, reason, format)
+	}
+
+	if strings.Count(origin, "*") > 1 {
+		return reject("only one wildcard is allowed per origin")
+	}
+
+	scheme, host, ok := cutOriginScheme(origin)
+	if !ok {
+		return reject("scheme must be http or https, and cannot itself be a wildcard")
+	}
+
+	if host == "" {
+		return reject("missing host")
+	}
+
+	// An origin is a scheme, host and port and nothing else.
+	if strings.ContainsAny(host, "/?#@") {
+		return reject("must not have a trailing slash, path, query, fragment or userinfo")
+	}
+
+	// A browser omits the port when it is the default for the scheme, so an
+	// entry that spells it out can never match.
+	if (scheme == "http" && strings.HasSuffix(host, ":80")) ||
+		(scheme == "https" && strings.HasSuffix(host, ":443")) {
+		return fmt.Errorf("invalid web API allowed origin %q: a browser omits the default port, so this would never match. Use %q", origin, origin[:strings.LastIndex(origin, ":")])
+	}
+
+	return nil
+}
+
+// cutOriginScheme splits an origin into its scheme and the rest.
+func cutOriginScheme(origin string) (scheme string, rest string, ok bool) {
+	lower := strings.ToLower(origin)
+	for _, s := range []string{"http", "https"} {
+		if prefix := s + "://"; strings.HasPrefix(lower, prefix) {
+			return s, origin[len(prefix):], true
+		}
+	}
+	return "", "", false
+}
+
 // ListenerGroup is a set of related BOS endpoints: one plaintext, and
 // optionally one for SSL clients. Both listen in plaintext — a load balancer
 // terminates TLS and forwards decrypted traffic to the SSL endpoint. Pairing
@@ -92,7 +138,8 @@ func (e Endpoint) AdvertisedHost() string {
 	return e.Group.BOSAdvertisedHostPlain
 }
 
-//go:generate go run ../cmd/config_generator unix settings.env ssl
+//go:generate go run ../cmd/config_generator unix settings.env basic
+//go:generate go run ../cmd/config_generator unix ssl/settings.env ssl
 type Config struct {
 	BOSListeners            []string `envconfig:"OSCAR_LISTENERS" required:"true" basic:"LOCAL://0.0.0.0:5190" ssl:"LOCAL://0.0.0.0:5190" description:"Network listeners for core OSCAR services. For multi-homed servers, allows users to connect from multiple networks. For example, you can allow both LAN and Internet clients to connect to the same server using different connection settings.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Listener names and ports must be unique\n\t- Listener names are user-defined\n\t- Each listener needs a listener in OSCAR_ADVERTISED_LISTENERS_PLAIN\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:5190\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:5190,LAN://192.168.1.10:5191"`
 	BOSAdvertisedHostsPlain []string `envconfig:"OSCAR_ADVERTISED_LISTENERS_PLAIN" required:"true" basic:"LOCAL://127.0.0.1:5190" ssl:"LOCAL://ras.dev:5190" description:"Hostnames published by the server that clients connect to for accessing various OSCAR services. These hostnames are NOT the bind addresses. For multi-homed use servers, allows clients to connect using separate hostnames per network.\n\nFormat:\n\t- Comma-separated list of [NAME]://[HOSTNAME]:[PORT]\n\t- Each listener config must correspond to a config in OSCAR_LISTENERS\n\t- Clients MUST be able to connect to these hostnames\n\nExamples:\n\t// Local LAN config, server behind NAT\n\tLAN://192.168.1.10:5190\n\t// Separate Internet and LAN config\n\tWAN://aim.example.com:5190,LAN://192.168.1.10:5191"`
@@ -102,6 +149,7 @@ type Config struct {
 	TOCListeners            []string `envconfig:"TOC_LISTENERS" required:"true" basic:"0.0.0.0:9898" ssl:"0.0.0.0:9898" description:"Network listeners for TOC protocol service.\n\nFormat: Comma-separated list of hostname:port pairs.\n\nExamples:\n\t// All interfaces\n\t0.0.0.0:9898\n\t// Multiple listeners\n\t0.0.0.0:9898,192.168.1.10:9899"`
 	APIListener             string   `envconfig:"API_LISTENER" required:"true" basic:"127.0.0.1:8080" ssl:"127.0.0.1:8080" description:"Network listener for management API binds to. Only 1 listener can be specified. (Default 127.0.0.1 restricts to same machine only)."`
 	WebAPIListeners         []string `envconfig:"WEBAPI_LISTENERS" required:"false" basic:"0.0.0.0:8081" ssl:"0.0.0.0:8081" description:"Network listeners for WebAPI. See OSCAR_LISTENERS doc for more details.\n\nExamples:\n\t// Listen on all interfaces\n\tLAN://0.0.0.0:8081\n\t// Separate Internet and LAN config\n\tWAN://142.250.176.206:8081,LAN://192.168.1.10:8082"`
+	WebAPIAllowedOrigins    []string `envconfig:"WEBAPI_ALLOWED_ORIGINS" required:"false" basic:"http://localhost:*" ssl:"http://localhost:*" description:"Origins allowed to call the WebAPI from a browser (CORS). A browser blocks a cross-origin response whose origin is not listed here, so the client serving the web app must appear in this list.\n\nFormat:\n\t- Comma-separated list of [SCHEME]://[HOSTNAME]:[PORT]\n\t- An origin is the scheme, host and port together: a client served from another port needs its own entry\n\t- Omit the port when it is the scheme default, the way a browser writes it: https://aim.example.com, not https://aim.example.com:443\n\t- No trailing slash, path, query or fragment\n\t- An entry may contain one wildcard (*) standing in for 0 or more characters, in the host or the port: https://*.example.com, http://localhost:* . Only one wildcard per entry, the scheme cannot be wildcarded, and matching one costs a little more per request\n\t- A lone * allows any origin, which is also what an unset or empty value means\n\nExamples:\n\t// Single origin\n\thttps://ras.dev\n\t// Web app on a separate port, plus the API host itself\n\thttp://localhost:8000,https://ras.dev\n\t// Any origin (development only)\n\t*"`
 
 	DBPath                 string `envconfig:"DB_PATH" required:"true" basic:"oscar.sqlite" ssl:"oscar.sqlite" description:"The path to the SQLite database file. The file and DB schema are auto-created if they doesn't exist."`
 	DisableAuth            bool   `envconfig:"DISABLE_AUTH" required:"true" basic:"true" ssl:"true" description:"Disable password check and auto-create new users at login time. Useful for quickly creating new accounts during development without having to register new users via the management API."`
@@ -386,5 +434,16 @@ func (c *Config) Validate() error {
 		}
 	}
 
+	// Validate WebAPIAllowedOrigins (format: scheme://host[:port], or * for any).
+	// An empty list is valid and means any origin is allowed.
+	for _, origin := range c.WebAPIAllowedOrigins {
+		origin = strings.TrimSpace(origin)
+		if origin == "" || origin == "*" {
+			continue
+		}
+		if err := validateAllowedOrigin(origin); err != nil {
+			return err
+		}
+	}
 	return nil
 }

+ 258 - 0
config/config_test.go

@@ -707,6 +707,264 @@ func TestConfigValidate(t *testing.T) {
 			wantErr:     true,
 			errContains: "invalid web API listener \"invalid-format\": address invalid-format: missing port in address",
 		},
+		{
+			name: "valid multiple web API allowed origins",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"https://aim.example.com", "http://localhost:8000", " http://ras.dev "},
+			},
+			wantErr: false,
+		},
+		{
+			name: "wildcard web API allowed origin",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"*"},
+			},
+			wantErr: false,
+		},
+		{
+			// An empty list is a valid setting meaning "allow any origin", not a
+			// misconfiguration. There is no way to express "deny all".
+			name: "empty web API allowed origins",
+			config: Config{
+				TOCListeners:    []string{"0.0.0.0:9898"},
+				APIListener:     "127.0.0.1:8080",
+				WebAPIListeners: []string{"0.0.0.0:8081"},
+			},
+			wantErr: false,
+		},
+		{
+			name: "whitespace-only web API allowed origins",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"  ", ""},
+			},
+			wantErr: false,
+		},
+		{
+			name: "invalid web API allowed origin - trailing slash",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"http://localhost:8000/"},
+			},
+			wantErr:     true,
+			errContains: "invalid web API allowed origin \"http://localhost:8000/\": must not have a trailing slash, path, query, fragment or userinfo",
+		},
+		{
+			name: "invalid web API allowed origin - has a path",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"https://aim.example.com/client"},
+			},
+			wantErr:     true,
+			errContains: "must not have a trailing slash, path, query, fragment or userinfo",
+		},
+		{
+			name: "invalid web API allowed origin - missing scheme",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"aim.example.com"},
+			},
+			wantErr:     true,
+			errContains: "scheme must be http or https",
+		},
+		{
+			name: "invalid web API allowed origin - unsupported scheme",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"ftp://aim.example.com"},
+			},
+			wantErr:     true,
+			errContains: "scheme must be http or https",
+		},
+		{
+			name: "valid web API allowed origin - port wildcard",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"http://localhost:*"},
+			},
+			wantErr: false,
+		},
+		{
+			name: "valid web API allowed origin - host wildcard with port",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"http://*:8000", "https://*"},
+			},
+			wantErr: false,
+		},
+		{
+			// Underscores are legal in a host and browsers send them verbatim.
+			name: "valid web API allowed origin - underscore host",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"http://my_host:8080"},
+			},
+			wantErr: false,
+		},
+		{
+			name: "valid web API allowed origin - IPv6 literal",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"http://[::1]:8000"},
+			},
+			wantErr: false,
+		},
+		{
+			name: "invalid web API allowed origin - two wildcards",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"http://*.example.*"},
+			},
+			wantErr:     true,
+			errContains: "only one wildcard is allowed per origin",
+		},
+		{
+			name: "invalid web API allowed origin - wildcard scheme",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"*://example.com"},
+			},
+			wantErr:     true,
+			errContains: "cannot itself be a wildcard",
+		},
+		{
+			name: "invalid web API allowed origin - wildcard with default port",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"https://*.example.com:443"},
+			},
+			wantErr:     true,
+			errContains: "a browser omits the default port, so this would never match. Use \"https://*.example.com\"",
+		},
+		{
+			name: "invalid web API allowed origin - wildcard with path",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"http://localhost:*/x"},
+			},
+			wantErr:     true,
+			errContains: "must not have a trailing slash, path, query, fragment or userinfo",
+		},
+		{
+			name: "invalid web API allowed origin - explicit https default port",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"https://ras.dev:443"},
+			},
+			wantErr:     true,
+			errContains: "a browser omits the default port, so this would never match. Use \"https://ras.dev\"",
+		},
+		{
+			name: "invalid web API allowed origin - explicit http default port",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"http://localhost:80"},
+			},
+			wantErr:     true,
+			errContains: "a browser omits the default port, so this would never match. Use \"http://localhost\"",
+		},
+		{
+			name: "invalid web API allowed origin - userinfo",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"http://user@example.com"},
+			},
+			wantErr:     true,
+			errContains: "must not have a trailing slash, path, query, fragment or userinfo",
+		},
+		{
+			name: "invalid web API allowed origin - forced empty query",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"https://example.com?"},
+			},
+			wantErr:     true,
+			errContains: "must not have a trailing slash, path, query, fragment or userinfo",
+		},
+		{
+			// A non-default port is part of the origin and must be kept.
+			name: "valid web API allowed origin - non-default port",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"http://localhost:8000", "https://ras.dev:8443"},
+			},
+			wantErr: false,
+		},
+		{
+			// rs/cors matches case-insensitively, so this does work at runtime.
+			name: "valid web API allowed origin - uppercase",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"HTTPS://RAS.DEV"},
+			},
+			wantErr: false,
+		},
+		{
+			// rs/cors supports one wildcard per entry.
+			name: "valid web API allowed origin - subdomain wildcard",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"https://*.example.com"},
+			},
+			wantErr: false,
+		},
+		{
+			name: "invalid web API allowed origin - missing host",
+			config: Config{
+				TOCListeners:         []string{"0.0.0.0:9898"},
+				APIListener:          "127.0.0.1:8080",
+				WebAPIListeners:      []string{"0.0.0.0:8081"},
+				WebAPIAllowedOrigins: []string{"https://"},
+			},
+			wantErr:     true,
+			errContains: "missing host",
+		},
 	}
 
 	for _, tt := range tests {

+ 27 - 0
config/settings.env

@@ -57,6 +57,33 @@ export API_LISTENER=127.0.0.1:8080
 # 	WAN://142.250.176.206:8081,LAN://192.168.1.10:8082
 export WEBAPI_LISTENERS=0.0.0.0:8081
 
+# Origins allowed to call the WebAPI from a browser (CORS). A browser blocks a
+# cross-origin response whose origin is not listed here, so the client serving
+# the web app must appear in this list.
+# 
+# Format:
+# 	- Comma-separated list of [SCHEME]://[HOSTNAME]:[PORT]
+# 	- An origin is the scheme, host and port together: a client served from
+# another port needs its own entry
+# 	- Omit the port when it is the scheme default, the way a browser writes it:
+# https://aim.example.com, not https://aim.example.com:443
+# 	- No trailing slash, path, query or fragment
+# 	- An entry may contain one wildcard (*) standing in for 0 or more characters,
+# in the host or the port: https://*.example.com, http://localhost:* . Only one
+# wildcard per entry, the scheme cannot be wildcarded, and matching one costs a
+# little more per request
+# 	- A lone * allows any origin, which is also what an unset or empty value
+# means
+# 
+# Examples:
+# 	// Single origin
+# 	https://ras.dev
+# 	// Web app on a separate port, plus the API host itself
+# 	http://localhost:8000,https://ras.dev
+# 	// Any origin (development only)
+# 	*
+export WEBAPI_ALLOWED_ORIGINS=http://localhost:*
+
 # The path to the SQLite database file. The file and DB schema are auto-created
 # if they doesn't exist.
 export DB_PATH=oscar.sqlite

+ 27 - 0
config/ssl/settings.env

@@ -93,6 +93,33 @@ export API_LISTENER=127.0.0.1:8080
 # 	WAN://142.250.176.206:8081,LAN://192.168.1.10:8082
 export WEBAPI_LISTENERS=0.0.0.0:8081
 
+# Origins allowed to call the WebAPI from a browser (CORS). A browser blocks a
+# cross-origin response whose origin is not listed here, so the client serving
+# the web app must appear in this list.
+# 
+# Format:
+# 	- Comma-separated list of [SCHEME]://[HOSTNAME]:[PORT]
+# 	- An origin is the scheme, host and port together: a client served from
+# another port needs its own entry
+# 	- Omit the port when it is the scheme default, the way a browser writes it:
+# https://aim.example.com, not https://aim.example.com:443
+# 	- No trailing slash, path, query or fragment
+# 	- An entry may contain one wildcard (*) standing in for 0 or more characters,
+# in the host or the port: https://*.example.com, http://localhost:* . Only one
+# wildcard per entry, the scheme cannot be wildcarded, and matching one costs a
+# little more per request
+# 	- A lone * allows any origin, which is also what an unset or empty value
+# means
+# 
+# Examples:
+# 	// Single origin
+# 	https://ras.dev
+# 	// Web app on a separate port, plus the API host itself
+# 	http://localhost:8000,https://ras.dev
+# 	// Any origin (development only)
+# 	*
+export WEBAPI_ALLOWED_ORIGINS=http://localhost:*
+
 # The path to the SQLite database file. The file and DB schema are auto-created
 # if they doesn't exist.
 export DB_PATH=oscar.sqlite

+ 1 - 0
go.mod

@@ -10,6 +10,7 @@ require (
 	github.com/kelseyhightower/envconfig v1.4.0
 	github.com/mitchellh/go-wordwrap v1.0.1
 	github.com/patrickmn/go-cache v2.1.0+incompatible
+	github.com/rs/cors v1.11.1
 	github.com/stretchr/testify v1.11.1
 	golang.org/x/net v0.48.0
 	golang.org/x/sync v0.19.0

+ 2 - 0
go.sum

@@ -28,6 +28,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
+github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
 github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
 github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=

+ 2 - 45
server/webapi/middleware.go

@@ -10,13 +10,12 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// AuthMiddleware provides session resolution and CORS handling for Web API
-// endpoints.
+// AuthMiddleware resolves the aimsid session for Web API endpoints.
 type AuthMiddleware struct {
 	Logger *slog.Logger
 }
 
-// NewAuthMiddleware creates a new authentication middleware instance.
+// NewAuthMiddleware creates a new session middleware instance.
 func NewAuthMiddleware(logger *slog.Logger) *AuthMiddleware {
 	return &AuthMiddleware{
 		Logger: logger,
@@ -49,48 +48,6 @@ func (m *AuthMiddleware) RequireSession(sm SessionResolver, next func(http.Respo
 	})
 }
 
-// CORSMiddleware emits CORS headers and answers preflight requests. Every origin
-// is allowed; the aimsid session is the security boundary.
-//
-// It must be the OUTERMOST middleware on every route. A response that the session
-// layer rejects still needs an Access-Control-Allow-Origin header: without one
-// the browser blocks the response, and the Web AIM client reads a status-0 empty
-// response as "CORS blocked" and permanently downgrades its whole request
-// pipeline to JSONP (aim.client.js onXhrFailed_ clears its useXhr flag and never
-// sets it again). A single 400/401 from the session layer is enough to latch it.
-//
-// It reads nothing from the request body, so a POST body reaches the handler
-// unconsumed.
-func (m *AuthMiddleware) CORSMiddleware(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		origin := r.Header.Get("Origin")
-
-		// The response body varies with the request Origin, so it must not be
-		// cached under a single key across origins.
-		w.Header().Add("Vary", "Origin")
-
-		// Echoing the origin back, rather than sending "*", is what lets the
-		// client send credentials.
-		if origin != "" {
-			w.Header().Set("Access-Control-Allow-Origin", origin)
-		} else {
-			w.Header().Set("Access-Control-Allow-Origin", "*")
-		}
-		w.Header().Set("Access-Control-Allow-Credentials", "true")
-		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
-		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
-		w.Header().Set("Access-Control-Max-Age", "3600")
-
-		// Handle preflight requests
-		if r.Method == "OPTIONS" {
-			w.WriteHeader(http.StatusNoContent)
-			return
-		}
-
-		next.ServeHTTP(w, r)
-	})
-}
-
 // SessionHandlerFunc is the session-aware handler shape that
 // AuthMiddleware.RequireSession invokes once it has resolved an aimsid.
 type SessionHandlerFunc = func(http.ResponseWriter, *http.Request, *Session)

+ 201 - 119
server/webapi/middleware_test.go

@@ -20,148 +20,230 @@ import (
 )
 
 func newTestMiddleware() *AuthMiddleware {
-	return NewAuthMiddleware(slog.New(slog.NewTextHandler(io.Discard, nil)))
+	return NewAuthMiddleware(discardLogger())
 }
 
 // The Web AIM client permanently downgrades to JSONP when a cross-origin
-// response arrives without Access-Control-Allow-Origin, so every response the
-// session layer rejects must still carry CORS headers. That only holds while
-// CORSMiddleware wraps the session middleware.
-func TestCORSMiddleware_HeadersOnSessionRejection(t *testing.T) {
-	// The session layer reports failures in the response envelope rather than the
-	// HTTP status, so the envelope's statusCode is what identifies a rejection.
+// response arrives without Access-Control-Allow-Origin: it reads the response
+// the browser blocked as a status-0 empty one, and aim.client.js onXhrFailed_
+// clears its useXhr flag and never sets it again. One uncovered response is
+// enough to latch that, so the CORS layer wraps the whole mux and every layer
+// below it — routed, rejected, and unrouted alike — answers with the header.
+//
+// These cases go through the handler the listener actually serves, so they
+// cover the configured allowlist end to end rather than the options struct.
+func TestServer_CORS(t *testing.T) {
+	// What most cases are configured with; a case overrides it to exercise the
+	// wildcard and empty modes.
+	defaultOrigins := []string{"https://ras.dev", "http://ras.dev", "http://localhost:8000"}
+
 	tests := []struct {
-		name         string
-		query        string
-		wantEnvelope string
+		name string
+		// nil means defaultOrigins.
+		allowedOrigins []string
+		method         string
+		path           string
+		origin         string
+		// Setting reqMethod makes the request a preflight.
+		reqMethod   string
+		reqHeaders  string
+		wantStatus  int
+		wantOrigin  string
+		wantMethods string
+		wantHeaders string
+		wantMaxAge  string
 	}{
+		// Each layer that can produce a response must carry the header.
+		{
+			name: "public auth endpoint", method: http.MethodPost, path: "/auth/clientLogin",
+			origin: "https://ras.dev", wantStatus: http.StatusBadRequest, wantOrigin: "https://ras.dev",
+		},
+		{
+			name: "session rejection", method: http.MethodGet, path: "/aim/fetchEvents",
+			origin: "https://ras.dev", wantStatus: http.StatusBadRequest, wantOrigin: "https://ras.dev",
+		},
+		{
+			name: "stub route", method: http.MethodGet, path: "/aim/getData",
+			origin: "https://ras.dev", wantStatus: http.StatusOK, wantOrigin: "https://ras.dev",
+		},
+		{
+			name: "unrouted 404", method: http.MethodGet, path: "/service/getAttributes/nope",
+			origin: "https://ras.dev", wantStatus: http.StatusNotFound, wantOrigin: "https://ras.dev",
+		},
+
+		// An origin is the scheme, host and port together, so the allowlist
+		// admits exactly what it names and nothing adjacent.
+		{
+			name: "allowed origin, other scheme", method: http.MethodGet, path: "/aim/getData",
+			origin: "http://ras.dev", wantStatus: http.StatusOK, wantOrigin: "http://ras.dev",
+		},
+		{
+			name: "allowed origin, other port", method: http.MethodGet, path: "/aim/getData",
+			origin: "http://localhost:8000", wantStatus: http.StatusOK, wantOrigin: "http://localhost:8000",
+		},
+		{
+			name: "different port is a different origin", method: http.MethodGet, path: "/aim/getData",
+			origin: "http://localhost", wantStatus: http.StatusOK,
+		},
+		{
+			name: "different scheme is a different origin", method: http.MethodGet, path: "/aim/getData",
+			origin: "https://localhost:8000", wantStatus: http.StatusOK,
+		},
+		{
+			// The suffix a naive HasSuffix check would wave through.
+			name: "attacker-registrable suffix", method: http.MethodGet, path: "/aim/getData",
+			origin: "https://ras.dev.evil.example", wantStatus: http.StatusOK,
+		},
+		{
+			name: "unlisted origin", method: http.MethodGet, path: "/aim/getData",
+			origin: "http://evil.example", wantStatus: http.StatusOK,
+		},
+		{
+			// A same-origin request sends no Origin, so there is nothing to allow.
+			name: "no origin header", method: http.MethodGet, path: "/aim/getData",
+			wantStatus: http.StatusOK,
+		},
+
+		// A preflight is answered by the CORS layer alone: it reaches no route,
+		// so it needs no aimsid and 404s on no path.
+		{
+			name: "preflight GET", method: http.MethodOptions, path: "/im/sendIM",
+			origin: "https://ras.dev", reqMethod: http.MethodGet,
+			wantStatus: http.StatusNoContent, wantOrigin: "https://ras.dev",
+			wantMethods: http.MethodGet, wantMaxAge: "3600",
+		},
+		{
+			// Lowercase, as the Fetch spec requires a browser to send it: the
+			// CORS layer matches these names byte for byte.
+			name: "preflight POST with a header", method: http.MethodOptions, path: "/im/sendIM",
+			origin: "https://ras.dev", reqMethod: http.MethodPost, reqHeaders: "content-type",
+			wantStatus: http.StatusNoContent, wantOrigin: "https://ras.dev",
+			wantMethods: http.MethodPost, wantHeaders: "content-type", wantMaxAge: "3600",
+		},
+		{
+			name: "preflight on an unrouted path", method: http.MethodOptions, path: "/totally/unrouted",
+			origin: "https://ras.dev", reqMethod: http.MethodPost,
+			wantStatus: http.StatusNoContent, wantOrigin: "https://ras.dev",
+			wantMethods: http.MethodPost, wantMaxAge: "3600",
+		},
+		{
+			// Every route is a GET or a POST, so anything else is refused with
+			// no CORS headers and the browser never sends the real request.
+			name: "preflight PUT refused", method: http.MethodOptions, path: "/im/sendIM",
+			origin: "https://ras.dev", reqMethod: http.MethodPut, wantStatus: http.StatusNoContent,
+		},
+		{
+			name: "preflight DELETE refused", method: http.MethodOptions, path: "/im/sendIM",
+			origin: "https://ras.dev", reqMethod: http.MethodDelete, wantStatus: http.StatusNoContent,
+		},
+		{
+			name: "preflight with an unlisted header refused", method: http.MethodOptions, path: "/im/sendIM",
+			origin: "https://ras.dev", reqMethod: http.MethodGet, reqHeaders: "x-not-allowed",
+			wantStatus: http.StatusNoContent,
+		},
+
+		// The configured modes.
+		{
+			name: "wildcard allows any origin", allowedOrigins: []string{"*"},
+			method: http.MethodGet, path: "/aim/getData", origin: "http://never.seen.example",
+			wantStatus: http.StatusOK, wantOrigin: "http://never.seen.example",
+		},
+		{
+			// envconfig splits on commas without trimming.
+			name: "entries are trimmed", allowedOrigins: []string{" https://ras.dev ", "  "},
+			method: http.MethodGet, path: "/aim/getData", origin: "https://ras.dev",
+			wantStatus: http.StatusOK, wantOrigin: "https://ras.dev",
+		},
+		{
+			// An unset or empty value means "any origin", same as a lone *.
+			name: "empty allowlist allows any origin", allowedOrigins: []string{"  "},
+			method: http.MethodGet, path: "/aim/getData", origin: "http://never.seen.example",
+			wantStatus: http.StatusOK, wantOrigin: "http://never.seen.example",
+		},
 		{
-			name:         "missing aimsid",
-			query:        "?f=json",
-			wantEnvelope: `"statusCode":400`,
+			name: "unset allowlist allows any origin", allowedOrigins: []string{},
+			method: http.MethodGet, path: "/aim/getData", origin: "http://never.seen.example",
+			wantStatus: http.StatusOK, wantOrigin: "http://never.seen.example",
 		},
 		{
-			name:         "unknown aimsid",
-			query:        "?f=json&aimsid=nosuchsession",
-			wantEnvelope: `"statusCode":401`,
+			// A wildcard may also stand in for the port.
+			name: "port wildcard matches any port", allowedOrigins: []string{"http://localhost:*"},
+			method: http.MethodGet, path: "/aim/getData", origin: "http://localhost:9999",
+			wantStatus: http.StatusOK, wantOrigin: "http://localhost:9999",
+		},
+		{
+			name: "port wildcard does not match another host", allowedOrigins: []string{"http://localhost:*"},
+			method: http.MethodGet, path: "/aim/getData", origin: "http://evil.example:9999",
+			wantStatus: http.StatusOK,
+		},
+		{
+			// One wildcard per entry, standing in for 0 or more characters.
+			name: "subdomain wildcard matches", allowedOrigins: []string{"https://*.example.com"},
+			method: http.MethodGet, path: "/aim/getData", origin: "https://web.example.com",
+			wantStatus: http.StatusOK, wantOrigin: "https://web.example.com",
+		},
+		{
+			name: "subdomain wildcard does not match another domain", allowedOrigins: []string{"https://*.example.com"},
+			method: http.MethodGet, path: "/aim/getData", origin: "https://web.evil.example",
+			wantStatus: http.StatusOK,
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			m := newTestMiddleware()
-			var reachedHandler bool
-			h := m.CORSMiddleware(m.RequireSession(&stubSessionResolver{},
-				func(w http.ResponseWriter, _ *http.Request, _ *Session) {
-					reachedHandler = true
-					w.WriteHeader(http.StatusOK)
-				}))
+			origins := tt.allowedOrigins
+			if origins == nil {
+				origins = defaultOrigins
+			}
+			h := testServerHandler(t, origins)
 
-			r := httptest.NewRequest(http.MethodGet, "/im/sendIM"+tt.query, nil)
-			r.Header.Set("Origin", "http://localhost:8000")
+			r := httptest.NewRequest(tt.method, tt.path, nil)
+			if tt.origin != "" {
+				r.Header.Set("Origin", tt.origin)
+			}
+			if tt.reqMethod != "" {
+				r.Header.Set("Access-Control-Request-Method", tt.reqMethod)
+			}
+			if tt.reqHeaders != "" {
+				r.Header.Set("Access-Control-Request-Headers", tt.reqHeaders)
+			}
 			w := httptest.NewRecorder()
 			h.ServeHTTP(w, r)
 
-			assert.False(t, reachedHandler, "session layer should have rejected the request")
-			assert.Contains(t, w.Body.String(), tt.wantEnvelope)
-			assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
-			assert.Equal(t, "Origin", w.Header().Get("Vary"))
-		})
-	}
-}
-
-// A 404 for an endpoint this server does not implement (/service/getAttributes,
-// /metrics/sendIM) must reach the client as a 404 rather than as a blocked
-// response.
-func TestCORSMiddleware_HeadersOn404(t *testing.T) {
-	m := newTestMiddleware()
-	h := m.CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
-		w.WriteHeader(http.StatusNotFound)
-	}))
-
-	r := httptest.NewRequest(http.MethodGet, "/service/getAttributes?f=json&aimsid=abc", nil)
-	r.Header.Set("Origin", "http://localhost:8000")
-	w := httptest.NewRecorder()
-	h.ServeHTTP(w, r)
+			assert.Equal(t, tt.wantStatus, w.Code)
+			// An allowed origin is echoed back rather than answered with "*";
+			// a rejected one gets no header at all, so the browser blocks it.
+			assert.Equal(t, tt.wantOrigin, w.Header().Get("Access-Control-Allow-Origin"))
+			assert.Contains(t, w.Header().Get("Vary"), "Origin")
 
-	assert.Equal(t, http.StatusNotFound, w.Code)
-	assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
-}
-
-// Every origin is allowed now that per-key allowlists are gone, so an origin the
-// server has never heard of still gets a usable response rather than one the
-// browser blocks.
-func TestCORSMiddleware_AllowsEveryOrigin(t *testing.T) {
-	m := newTestMiddleware()
-	h := m.CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
-		w.WriteHeader(http.StatusOK)
-	}))
+			// Credentials are not enabled, so the browser is never told to send them.
+			assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials"))
 
-	t.Run("unknown origin is echoed back", func(t *testing.T) {
-		r := httptest.NewRequest(http.MethodGet, "/im/sendIM", nil)
-		r.Header.Set("Origin", "http://never.seen.example")
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-
-		// Echoing the origin rather than "*" is what makes the response usable
-		// with credentials.
-		assert.Equal(t, "http://never.seen.example", w.Header().Get("Access-Control-Allow-Origin"))
-		assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials"))
-		assert.Equal(t, "Origin", w.Header().Get("Vary"))
-	})
-
-	// A same-origin request sends no Origin at all; there is nothing to echo.
-	t.Run("no origin header falls back to a wildcard", func(t *testing.T) {
-		r := httptest.NewRequest(http.MethodGet, "/im/sendIM", nil)
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-
-		assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin"))
-	})
+			// These answer a preflight and mean nothing on any other response.
+			assert.Equal(t, tt.wantMethods, w.Header().Get("Access-Control-Allow-Methods"))
+			assert.Equal(t, tt.wantHeaders, strings.ToLower(w.Header().Get("Access-Control-Allow-Headers")))
+			assert.Equal(t, tt.wantMaxAge, w.Header().Get("Access-Control-Max-Age"))
+		})
+	}
 }
 
-func TestCORSMiddleware_PreflightShortCircuits(t *testing.T) {
-	m := newTestMiddleware()
-	var reachedNext bool
-	h := m.CORSMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
-		reachedNext = true
-	}))
-
-	r := httptest.NewRequest(http.MethodOptions, "/im/sendIM", nil)
-	r.Header.Set("Origin", "http://localhost:8000")
-	w := httptest.NewRecorder()
-	h.ServeHTTP(w, r)
-
-	assert.Equal(t, http.StatusNoContent, w.Code)
-	assert.False(t, reachedNext, "preflight must not reach the wrapped handler")
-	assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
-	assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "POST")
+// testServerHandler builds the handler the listener actually serves, so the CORS
+// layer under test is the one NewServer configures.
+func testServerHandler(t *testing.T, allowedOrigins []string) http.Handler {
+	t.Helper()
+	handler := Handler{
+		Logger:         slog.Default(),
+		AllowedOrigins: allowedOrigins,
+	}
+	srv := NewServer([]string{"127.0.0.1:0"}, slog.Default(), handler, NewSessionManager())
+	require.NotEmpty(t, srv.servers)
+	return srv.servers[0].Handler
 }
 
-// A POST body must survive the middleware chain: CORSMiddleware reads nothing
-// from the request, so it never parses the form.
-func TestCORSMiddleware_DoesNotConsumePOSTBody(t *testing.T) {
-	m := newTestMiddleware()
-	h := m.CORSMiddleware(m.RequireSession(okSessionResolver{},
-		func(w http.ResponseWriter, r *http.Request, _ *Session) {
-			body, err := io.ReadAll(r.Body)
-			require.NoError(t, err)
-			assert.Equal(t, "message=hello", string(body))
-			w.WriteHeader(http.StatusOK)
-		}))
-
-	r := httptest.NewRequest(http.MethodPost, "/im/sendIM?aimsid=abc", strings.NewReader("message=hello"))
-	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-	r.Header.Set("Origin", "http://localhost:8000")
-	w := httptest.NewRecorder()
-	h.ServeHTTP(w, r)
-
-	assert.Equal(t, http.StatusOK, w.Code)
+func discardLogger() *slog.Logger {
+	return slog.New(slog.NewTextHandler(io.Discard, nil))
 }
 
-// Some clients POST the whole parameter set in the body, so a session layer
-// reading only the query string sees no aimsid. Reading the body here does not
-// cost the handler its parameters: ParseForm caches onto the request.
 func TestRequireSession_ReadsAimsidFromPOSTBody(t *testing.T) {
 	tests := []struct {
 		name        string
@@ -184,12 +266,12 @@ func TestRequireSession_ReadsAimsidFromPOSTBody(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			m := newTestMiddleware()
 			reached := false
-			h := m.CORSMiddleware(m.RequireSession(okSessionResolver{},
+			h := m.RequireSession(okSessionResolver{},
 				func(w http.ResponseWriter, r *http.Request, _ *Session) {
 					reached = true
 					assert.Equal(t, "hello", param(r, "message"))
 					w.WriteHeader(http.StatusOK)
-				}))
+				})
 
 			r := httptest.NewRequest(http.MethodPost, "/im/sendIM", strings.NewReader(tt.body))
 			if tt.contentType != "" {

+ 102 - 160
server/webapi/server.go

@@ -7,8 +7,10 @@ import (
 	"fmt"
 	"log/slog"
 	"net/http"
+	"strings"
 	"time"
 
+	"github.com/rs/cors"
 	"golang.org/x/sync/errgroup"
 
 	"github.com/mk6i/open-oscar-server/config"
@@ -16,6 +18,14 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
+// allowAnyOrigin allows every origin through AllowOriginFunc, which echoes the
+// request origin back. Leaving AllowedOrigins empty would also allow everything,
+// but the library answers that with a literal "*".
+func allowAnyOrigin(opts cors.Options) cors.Options {
+	opts.AllowOriginFunc = func(string) bool { return true }
+	return opts
+}
+
 func NewServer(listeners []string, logger *slog.Logger, handler Handler, sessionManager *SessionManager) *Server {
 	servers := make([]*http.Server, 0, len(listeners))
 
@@ -75,209 +85,109 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, session
 		Logger:           logger,
 	}
 
-	shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
-
-	for _, l := range listeners {
-		mux := http.NewServeMux()
-
-		// CORSMiddleware wraps the session layer rather than the other way around,
-		// so that responses the session layer rejects (400 missing aimsid, 401
-		// expired session) still carry Access-Control-Allow-Origin. A browser
-		// blocks a cross-origin response without that header, and the Web AIM
-		// client reads the resulting status-0 empty response as a CORS failure and
-		// permanently switches its whole request pipeline to JSONP.
-		//
-		// oscarRoute charges the request against the rate class for (foodGroup,
-		// subGroup) before the handler runs; sessionRoute and stubRoute reach no
-		// food group and so are not rate limited here.
-		oscarRoute := func(foodGroup uint16, subGroup uint16, h SessionHandlerFunc) http.Handler {
-			return authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager,
-					rateLimiter.OSCAR(foodGroup, subGroup)(h)))
-		}
-		sessionRoute := func(h SessionHandlerFunc) http.Handler {
-			return authMiddleware.CORSMiddleware(
-				authMiddleware.RequireSession(sessionManager, h))
-		}
-		stubRoute := func(h http.HandlerFunc) http.Handler {
-			return authMiddleware.CORSMiddleware(h)
-		}
-
-		mux.Handle("GET /{$}", http.HandlerFunc(handler.GetHelloWorldHandler))
+	expressionsHandler := NewExpressionsHandler(
+		handler.IconSource, handler.BARTService, handler.FeedbagService, logger)
 
-		// Unauthenticated and outside every middleware: Flash Player fetches the
-		// policy before it has a session, and refuses to look at a redirect or an
-		// error envelope.
-		mux.Handle("GET /crossdomain.xml", &CrossDomainPolicyHandler{Logger: logger})
+	crossDomainHandler := &CrossDomainPolicyHandler{Logger: logger}
+	conversationStub := &ConversationStubHandler{Logger: logger}
+	lifestreamStub := &UserInfoStubHandler{Logger: logger}
+	serviceStub := &ServiceStubHandler{Logger: logger}
 
-		mux.Handle("POST /auth/clientLogin", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			// Set CORS headers for public endpoint
-			w.Header().Set("Access-Control-Allow-Origin", "*")
-			w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
-			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+	oscarRoute := func(foodGroup uint16, subGroup uint16, h SessionHandlerFunc) http.Handler {
+		return authMiddleware.RequireSession(sessionManager,
+			rateLimiter.OSCAR(foodGroup, subGroup)(h))
+	}
+	sessionRoute := func(h SessionHandlerFunc) http.Handler {
+		return authMiddleware.RequireSession(sessionManager, h)
+	}
 
-			authHandler.ClientLogin(w, r)
-		}))
+	loginPSP := http.HandlerFunc(authHandler.LoginPSP)
+	startSession := http.HandlerFunc(aimHandler.StartSession)
+	sendIM := oscarRoute(wire.ICBM, wire.ICBMChannelMsgToHost, messagingHandler.SendIM)
+	memberDirUpdate := oscarRoute(wire.Locate, wire.LocateSetDirInfo, memberDirHandler.Update)
 
-		// Handle OPTIONS for CORS preflight
-		mux.HandleFunc("OPTIONS /auth/clientLogin", func(w http.ResponseWriter, r *http.Request) {
-			w.Header().Set("Access-Control-Allow-Origin", "*")
-			w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
-			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
-			w.WriteHeader(http.StatusNoContent)
-		})
+	corsHandler := cors.New(corsOptions(logger, handler.AllowedOrigins))
 
-		mux.Handle("GET /auth/getToken", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			w.Header().Set("Access-Control-Allow-Origin", "*")
-			w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
-			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
-			authHandler.GetToken(w, r)
-		}))
-
-		mux.HandleFunc("OPTIONS /auth/getToken", func(w http.ResponseWriter, r *http.Request) {
-			w.Header().Set("Access-Control-Allow-Origin", "*")
-			w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
-			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
-			w.WriteHeader(http.StatusNoContent)
-		})
+	shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
 
-		// No SSO cookie is involved, so this sits outside the session middleware.
-		// Both methods, since clients differ on which they use.
-		getInfo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			w.Header().Set("Access-Control-Allow-Origin", "*")
-			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
-			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
-			authHandler.GetInfo(w, r)
-		})
-		mux.Handle("GET /auth/getInfo", getInfo)
-		mux.Handle("POST /auth/getInfo", getInfo)
-
-		mux.HandleFunc("OPTIONS /auth/getInfo", func(w http.ResponseWriter, r *http.Request) {
-			w.Header().Set("Access-Control-Allow-Origin", "*")
-			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
-			w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
-			w.WriteHeader(http.StatusNoContent)
-		})
+	for _, l := range listeners {
+		mux := http.NewServeMux()
 
-		// Web AIM navigates the browser here on File > Logout; clear SSO state
-		// and redirect to the login screen.
-		mux.Handle("GET /auth/logout", http.HandlerFunc(authHandler.Logout))
+		mux.HandleFunc("GET /{$}", handler.GetHelloWorldHandler)
+		mux.Handle("GET /crossdomain.xml", crossDomainHandler)
 
-		// Wrapped in CORS: besides the browser navigation that renders the form,
-		// the client fetches this cross-origin for its client2Web SSO handoff, and
-		// a response without Access-Control-Allow-Origin reaches it as an ioError.
-		loginPSP := authMiddleware.CORSMiddleware(http.HandlerFunc(authHandler.LoginPSP))
+		mux.HandleFunc("POST /auth/clientLogin", authHandler.ClientLogin)
+		mux.HandleFunc("GET /auth/getToken", authHandler.GetToken)
+		mux.HandleFunc("GET /auth/getInfo", authHandler.GetInfo)
+		mux.HandleFunc("POST /auth/getInfo", authHandler.GetInfo)
+		mux.HandleFunc("GET /auth/logout", authHandler.Logout)
 		mux.Handle("GET /_cqr/login/login.psp", loginPSP)
 		mux.Handle("POST /_cqr/login/login.psp", loginPSP)
 
-		startSession := authMiddleware.CORSMiddleware(http.HandlerFunc(aimHandler.StartSession))
 		mux.Handle("GET /aim/startSession", startSession)
 		mux.Handle("POST /aim/startSession", startSession)
-
 		mux.Handle("GET /aim/endSession", sessionRoute(aimHandler.EndSession))
-
 		mux.Handle("GET /aim/fetchEvents", sessionRoute(aimHandler.FetchEvents))
+		mux.HandleFunc("GET /aim/startOSCARSession", aimHandler.StartOSCARSession)
 
-		mux.Handle("GET /aim/addTempBuddy", oscarRoute(wire.Buddy, wire.BuddyAddTempBuddies, aimHandler.AddTempBuddy))
-		mux.Handle("GET /aim/removeTempBuddy", oscarRoute(wire.Buddy, wire.BuddyDelTempBuddies, aimHandler.RemoveTempBuddy))
-
-		mux.Handle("GET /aim/setForwardDomain", stubRoute(aimHandler.SetForwardDomain))
-		mux.Handle("GET /aim/getData", stubRoute(aimHandler.GetData))
-		mux.Handle("GET /aim/reportAction", stubRoute(aimHandler.ReportAction))
-
-		// OSCAR Bridge endpoint. Hands off to a BOS session rather than reaching
-		// a food group, so there is no OSCAR budget to charge.
-		mux.Handle("GET /aim/startOSCARSession",
-			authMiddleware.CORSMiddleware(http.HandlerFunc(aimHandler.StartOSCARSession)))
-
-		conversationStub := &ConversationStubHandler{
-			Logger: logger,
-		}
-		mux.Handle("GET /conversation/update", stubRoute(conversationStub.Update))
-		mux.Handle("GET /conversation/close", stubRoute(conversationStub.Close))
-		mux.Handle("GET /imlog/markRead", stubRoute(conversationStub.MarkRead))
-		mux.Handle("GET /imlog/fetchStoredIMs", sessionRoute(conversationStub.FetchStoredIMs))
-
-		// Presence and buddy list
 		mux.Handle("GET /presence/get", oscarRoute(wire.Feedbag, wire.FeedbagQuery, presenceHandler.GetPresence))
+		mux.Handle("GET /presence/setState", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetState))
+		mux.Handle("GET /presence/setStatus", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetStatus))
+		mux.Handle("GET /presence/getProfile", oscarRoute(wire.Locate, wire.LocateUserInfoQuery, presenceHandler.GetProfile))
+		mux.Handle("GET /presence/setProfile", oscarRoute(wire.Locate, wire.LocateSetInfo, presenceHandler.SetProfile))
+		mux.HandleFunc("GET /presence/icon", presenceHandler.Icon)
 
 		mux.Handle("GET /buddylist/addBuddy", oscarRoute(wire.Feedbag, wire.FeedbagInsertItem, buddyListHandler.AddBuddy))
 		mux.Handle("GET /buddylist/addGroup", oscarRoute(wire.Feedbag, wire.FeedbagInsertItem, buddyListHandler.AddGroup))
 		mux.Handle("GET /buddylist/removeBuddy", oscarRoute(wire.Feedbag, wire.FeedbagDeleteItem, buddyListHandler.RemoveBuddy))
 		mux.Handle("GET /buddylist/removeGroup", oscarRoute(wire.Feedbag, wire.FeedbagDeleteItem, buddyListHandler.RemoveGroup))
-		mux.Handle("GET /buddylist/renameGroup", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.RenameGroup))
 		mux.Handle("GET /buddylist/moveBuddy", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.MoveBuddy))
+		mux.Handle("GET /buddylist/renameGroup", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.RenameGroup))
 		mux.Handle("GET /buddylist/setBuddyAttribute", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.SetBuddyAttribute))
 		mux.Handle("GET /buddylist/setGroupAttribute", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.SetGroupAttribute))
+		mux.Handle("GET /aim/addTempBuddy", oscarRoute(wire.Buddy, wire.BuddyAddTempBuddies, aimHandler.AddTempBuddy))
+		mux.Handle("GET /aim/removeTempBuddy", oscarRoute(wire.Buddy, wire.BuddyDelTempBuddies, aimHandler.RemoveTempBuddy))
 
 		// The Web AIM client POSTs the message body (non-IE browsers); IE uses GET.
-		sendIMHandler := oscarRoute(wire.ICBM, wire.ICBMChannelMsgToHost, messagingHandler.SendIM)
-		mux.Handle("GET /im/sendIM", sendIMHandler)
-		mux.Handle("POST /im/sendIM", sendIMHandler)
-
+		mux.Handle("GET /im/sendIM", sendIM)
+		mux.Handle("POST /im/sendIM", sendIM)
 		mux.Handle("GET /im/setTyping", oscarRoute(wire.ICBM, wire.ICBMClientEvent, messagingHandler.SetTyping))
-
-		mux.Handle("GET /presence/setState", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetState))
-
-		mux.Handle("GET /presence/setStatus", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetStatus))
-		mux.Handle("GET /presence/setProfile", oscarRoute(wire.Locate, wire.LocateSetInfo, presenceHandler.SetProfile))
-		mux.Handle("GET /presence/getProfile", oscarRoute(wire.Locate, wire.LocateUserInfoQuery, presenceHandler.GetProfile))
-
-		// Unauthenticated, like /expressions/get below: buddy icons load as plain
-		// <img> sources that carry no aimsid.
-		mux.Handle("GET /presence/icon", http.HandlerFunc(presenceHandler.Icon))
+		mux.Handle("GET /imlog/fetchStoredIMs", sessionRoute(conversationStub.FetchStoredIMs))
+		mux.HandleFunc("GET /imlog/markRead", conversationStub.MarkRead)
+		mux.HandleFunc("GET /conversation/update", conversationStub.Update)
+		mux.HandleFunc("GET /conversation/close", conversationStub.Close)
 
 		mux.Handle("GET /memberDir/search", oscarRoute(wire.ODir, wire.ODirInfoQuery, memberDirHandler.Search))
 		mux.Handle("GET /memberDir/get", oscarRoute(wire.Locate, wire.LocateGetDirInfo, memberDirHandler.Get))
-		memberDirUpdate := oscarRoute(wire.Locate, wire.LocateSetDirInfo, memberDirHandler.Update)
 		mux.Handle("GET /memberDir/update", memberDirUpdate)
 		mux.Handle("POST /memberDir/update", memberDirUpdate)
 
-		mux.Handle("GET /preference/set", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPreferences))
 		mux.Handle("GET /preference/get", oscarRoute(wire.Feedbag, wire.FeedbagQuery, preferenceHandler.GetPreferences))
-		mux.Handle("GET /preference/setPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPermitDeny))
+		mux.Handle("GET /preference/set", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPreferences))
 		mux.Handle("GET /preference/getPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagQuery, preferenceHandler.GetPermitDeny))
+		mux.Handle("GET /preference/setPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPermitDeny))
 
-		// Expressions endpoint (for buddy icons, etc.).
-		expressionsHandler := NewExpressionsHandler(
-			handler.IconSource, handler.BARTService, handler.FeedbagService, logger)
-		mux.Handle("GET /expressions/get",
-			authMiddleware.CORSMiddleware(
-				http.HandlerFunc(expressionsHandler.Get)))
-		// WithBinaryBody: the body is the raw image, and a missed parameter lookup
-		// would otherwise feed it to ParseForm and consume it.
+		mux.HandleFunc("GET /expressions/get", expressionsHandler.Get)
 		mux.Handle("POST /expressions/upload",
 			WithBinaryBody(oscarRoute(wire.BART, wire.BARTUploadQuery, expressionsHandler.Upload)))
 
-		// Web AIM calls lifestream/* on the API host (e.g. /lifestream/getUserDetails).
-		lifestreamStub := &UserInfoStubHandler{Logger: logger}
-		// getUserDetails returns a minimal AIM identity and getServices the service
-		// list behind it. Every other lifestream/* method is an unimplemented
-		// social-feed feature; the subtree catch-all acknowledges them with an
-		// empty 200 so the client doesn't error.
-		mux.Handle("GET /lifestream/getUserDetails", stubRoute(lifestreamStub.GetUserDetails))
-		mux.Handle("GET /lifestream/getServices", stubRoute(lifestreamStub.GetServices))
-		mux.Handle("GET /lifestream/heyGetNotifications", stubRoute(lifestreamStub.HeyGetNotifications))
-		mux.Handle("GET /lifestream/", stubRoute(lifestreamStub.EmptyOK))
-
-		// The client probes for a linked Google Talk account as soon as the
-		// session comes up, and its callback dereferences response.data unless
-		// the status says the service is absent.
-		serviceStub := &ServiceStubHandler{Logger: logger}
-		mux.Handle("GET /service/getAttributes", stubRoute(serviceStub.GetAttributes))
-
-		mux.Handle("OPTIONS /", authMiddleware.CORSMiddleware(
-			http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
-
-		mux.Handle("/", authMiddleware.CORSMiddleware(
-			http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-				logger.Debug("webapi 404", "method", r.Method, "path", r.URL.Path)
-				SendError(w, r, http.StatusNotFound, "not found")
-			})))
+		mux.HandleFunc("GET /aim/setForwardDomain", aimHandler.SetForwardDomain)
+		mux.HandleFunc("GET /aim/getData", aimHandler.GetData)
+		mux.HandleFunc("GET /aim/reportAction", aimHandler.ReportAction)
+		mux.HandleFunc("GET /lifestream/getUserDetails", lifestreamStub.GetUserDetails)
+		mux.HandleFunc("GET /lifestream/getServices", lifestreamStub.GetServices)
+		mux.HandleFunc("GET /lifestream/heyGetNotifications", lifestreamStub.HeyGetNotifications)
+		mux.HandleFunc("GET /lifestream/", lifestreamStub.EmptyOK)
+		mux.HandleFunc("GET /service/getAttributes", serviceStub.GetAttributes)
+
+		mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+			logger.Debug("webapi 404", "method", r.Method, "path", r.URL.Path)
+			SendError(w, r, http.StatusNotFound, "not found")
+		})
 
 		servers = append(servers, &http.Server{
 			Addr:    l,
-			Handler: RequestLogger(logger, mux),
+			Handler: RequestLogger(logger, corsHandler.Handler(mux)),
 		})
 	}
 
@@ -429,6 +339,7 @@ type Handler struct {
 	BuddyBroadcaster   BuddyBroadcaster
 	BuddyService       BuddyService
 	BOSListener        config.ListenerGroup
+	AllowedOrigins     []string
 	BuddyListManager   *BuddyListManager
 	RecalcWarning      func(ctx context.Context, instance *state.SessionInstance) error
 	LowerWarnLevel     func(ctx context.Context, instance *state.SessionInstance)
@@ -454,3 +365,34 @@ func (h Handler) GetHelloWorldHandler(w http.ResponseWriter, r *http.Request) {
 	}
 	_ = json.NewEncoder(w).Encode(resp)
 }
+
+// corsOptions maps the configured origin allowlist onto rs/cors.
+func corsOptions(logger *slog.Logger, allowedOrigins []string) cors.Options {
+	opts := cors.Options{
+		AllowCredentials: false,
+		AllowedMethods:   []string{http.MethodGet, http.MethodPost},
+		AllowedHeaders:   []string{"Content-Type", "Authorization"},
+		MaxAge:           3600,
+	}
+
+	origins := make([]string, 0, len(allowedOrigins))
+	for _, origin := range allowedOrigins {
+		origin = strings.TrimSpace(origin)
+		if origin == "" {
+			continue
+		}
+		if origin == "*" {
+			logger.Info("WEBAPI_ALLOWED_ORIGINS is *, allowing browser calls from any origin")
+			return allowAnyOrigin(opts)
+		}
+		origins = append(origins, origin)
+	}
+
+	if len(origins) == 0 {
+		logger.Info("WEBAPI_ALLOWED_ORIGINS is not set, allowing browser calls from any origin")
+		return allowAnyOrigin(opts)
+	}
+
+	opts.AllowedOrigins = origins
+	return opts
+}