mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 22:30:43 +00:00
* feat(admin): update runtime CORS origins consistently * test(admin): preserve rate-limit headers after CORS updates * test(admin): preserve router connection budget after rebase
130 lines
4.0 KiB
Go
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
|
|
}
|