Files
beacon-server/internal/api/middleware/runtime_config.go
T
n30nex 775aedfd2a feat(admin): update runtime CORS origins consistently (#150)
* feat(admin): update runtime CORS origins consistently

* test(admin): preserve rate-limit headers after CORS updates

* test(admin): preserve router connection budget after rebase
2026-09-19 15:27:25 -04:00

130 lines
4.0 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package middleware
import (
"errors"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"unicode"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/go-chi/cors"
)
var ErrInvalidOrigins = errors.New("allowed_origins must contain 1-32 ASCII HTTP(S) origins (at most 512 bytes each), or a sole *; paths, credentials, queries and control characters are not allowed")
type runtimePolicy struct {
config api.AdminConfig
handler http.Handler
}
// RuntimeConfig owns one router's CORS handler and its nonsecret config view.
// Published policies are immutable. Updates are runtime-only, never persisted.
type RuntimeConfig struct {
mu sync.Mutex
current atomic.Pointer[runtimePolicy]
next http.Handler
exposedHeaders []string
}
func NewRuntimeConfig(config api.AdminConfig, exposedHeaders []string) *RuntimeConfig {
s := &RuntimeConfig{exposedHeaders: slices.Clone(exposedHeaders)}
s.current.Store(&runtimePolicy{config: cloneAdminConfig(config)})
return s
}
// CORS is bound once as the outer middleware of its owning router.
func (s *RuntimeConfig) CORS(next http.Handler) http.Handler {
s.mu.Lock()
defer s.mu.Unlock()
if s.next != nil {
panic("runtime CORS already bound")
}
s.next = next
s.current.Store(s.policy(s.current.Load().config))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.current.Load().handler.ServeHTTP(w, r)
})
}
func (s *RuntimeConfig) Snapshot() api.AdminConfig {
return cloneAdminConfig(s.current.Load().config)
}
func (s *RuntimeConfig) UpdateOrigins(origins []string) (api.AdminConfig, error) {
validated, err := validateOrigins(origins)
if err != nil {
return api.AdminConfig{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
config := cloneAdminConfig(s.current.Load().config)
config.CORS.AllowedOrigins = validated
s.current.Store(s.policy(config))
return cloneAdminConfig(config), nil
}
func (s *RuntimeConfig) policy(config api.AdminConfig) *runtimePolicy {
p := &runtimePolicy{config: config}
if s.next != nil {
p.handler = cors.Handler(cors.Options{
AllowedOrigins: slices.Clone(config.CORS.AllowedOrigins),
AllowedMethods: slices.Clone(config.CORS.AllowedMethods),
AllowedHeaders: slices.Clone(config.CORS.AllowedHeaders),
AllowCredentials: config.CORS.AllowCredentials, MaxAge: config.CORS.MaxAge,
ExposedHeaders: slices.Clone(s.exposedHeaders),
})(s.next)
}
return p
}
func cloneAdminConfig(config api.AdminConfig) api.AdminConfig {
config.CORS.AllowedOrigins = slices.Clone(config.CORS.AllowedOrigins)
config.CORS.AllowedMethods = slices.Clone(config.CORS.AllowedMethods)
config.CORS.AllowedHeaders = slices.Clone(config.CORS.AllowedHeaders)
return config
}
func validateOrigins(origins []string) ([]string, error) {
if len(origins) == 0 || len(origins) > 32 {
return nil, ErrInvalidOrigins
}
result := make([]string, len(origins))
for i, origin := range origins {
if len(origin) > 512 || strings.IndexFunc(origin, unicode.IsControl) >= 0 {
return nil, ErrInvalidOrigins
}
origin = strings.TrimSpace(origin)
if strings.IndexFunc(origin, func(r rune) bool { return r > unicode.MaxASCII }) >= 0 {
return nil, ErrInvalidOrigins
}
origin = strings.ToLower(origin)
if origin == "*" {
if len(origins) != 1 {
return nil, ErrInvalidOrigins
}
result[i] = origin
continue
}
u, err := url.Parse(origin)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(origin, "#") || strings.Count(u.Host, "*") > 1 || strings.Contains(u.Host, "%") || strings.HasSuffix(u.Host, ":") {
return nil, ErrInvalidOrigins
}
if port := u.Port(); port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return nil, ErrInvalidOrigins
}
}
result[i] = origin
}
return result, nil
}