| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- package server
- import (
- "context"
- "encoding/json"
- "errors"
- "net"
- "net/http"
- "net/netip"
- "strings"
- "vocat/internal/store"
- )
- const accessSettingKey = "security.access"
- // accessConfig is the persisted network access policy.
- type accessConfig struct {
- Mode string `json:"mode"` // "internal" (default) or "public"
- AllowedCIDRs []string `json:"allowed_cidrs"` // extra CIDRs always allowed
- TrustProxyHeaders bool `json:"trust_proxy_headers"` // honor X-Forwarded-For
- }
- // parsedAccessConfig is the validated runtime form of accessConfig.
- type parsedAccessConfig struct {
- mode string
- cidrs []netip.Prefix
- trustProxy bool
- }
- // internalNetworks are always allowed when mode is "internal": loopback,
- // RFC1918 private ranges, link-local, and IPv6 ULA.
- var internalNetworks = []netip.Prefix{
- netip.MustParsePrefix("127.0.0.0/8"),
- netip.MustParsePrefix("10.0.0.0/8"),
- netip.MustParsePrefix("172.16.0.0/12"),
- netip.MustParsePrefix("192.168.0.0/16"),
- netip.MustParsePrefix("169.254.0.0/16"),
- netip.MustParsePrefix("::1/128"),
- netip.MustParsePrefix("fe80::/10"),
- netip.MustParsePrefix("fc00::/7"),
- }
- func defaultAccessConfig() parsedAccessConfig {
- return parsedAccessConfig{mode: "internal"}
- }
- // parseAccessConfig validates and parses a persisted access policy.
- func parseAccessConfig(config accessConfig) (parsedAccessConfig, error) {
- mode := strings.ToLower(strings.TrimSpace(config.Mode))
- if mode == "" {
- mode = "internal"
- }
- if mode != "internal" && mode != "public" {
- return parsedAccessConfig{}, errors.New("mode must be \"internal\" or \"public\"")
- }
- parsed := parsedAccessConfig{
- mode: mode,
- trustProxy: config.TrustProxyHeaders,
- }
- for _, raw := range config.AllowedCIDRs {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- continue
- }
- if prefix, err := netip.ParsePrefix(raw); err == nil {
- parsed.cidrs = append(parsed.cidrs, prefix.Masked())
- continue
- }
- if address, err := netip.ParseAddr(raw); err == nil {
- bits := 32
- if address.Is6() {
- bits = 128
- }
- parsed.cidrs = append(parsed.cidrs, netip.PrefixFrom(address, bits))
- continue
- }
- return parsedAccessConfig{}, errors.New("invalid CIDR or IP: " + raw)
- }
- return parsed, nil
- }
- // allowed reports whether a client address may reach the service.
- func (config parsedAccessConfig) allowed(address netip.Addr) bool {
- if !address.IsValid() {
- return false
- }
- // Normalize IPv4-mapped IPv6 addresses (e.g. ::ffff:192.168.1.5 seen on
- // dual-stack listeners) to their IPv4 form so they match the internal
- // ranges below; without this they would be denied even though they are
- // ordinary internal IPv4 clients.
- address = address.Unmap()
- if config.mode == "public" {
- return true
- }
- if address.IsLoopback() {
- return true
- }
- for _, prefix := range internalNetworks {
- if prefix.Contains(address) {
- return true
- }
- }
- for _, prefix := range config.cidrs {
- if prefix.Contains(address) {
- return true
- }
- }
- return false
- }
- // clientIP determines the request's source address, honoring X-Forwarded-For
- // only when the deployment is configured to trust proxy headers.
- func (config parsedAccessConfig) clientIP(r *http.Request) netip.Addr {
- if config.trustProxy {
- if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
- first := strings.TrimSpace(strings.Split(forwarded, ",")[0])
- if address, err := netip.ParseAddr(first); err == nil {
- return address.Unmap()
- }
- }
- if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" {
- if address, err := netip.ParseAddr(real); err == nil {
- return address.Unmap()
- }
- }
- }
- host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
- if err != nil {
- host = strings.TrimSpace(r.RemoteAddr)
- }
- address, err := netip.ParseAddr(host)
- if err != nil {
- return netip.Addr{}
- }
- // Report the canonical (unmapped) form so logs, the login rate-limit key,
- // and the access decision all agree on one representation of an IPv4 client.
- return address.Unmap()
- }
- // accessControl rejects requests whose source IP is outside the configured
- // access policy. It wraps the whole mux so every route (API, SPA, websheets) is
- // protected uniformly.
- func (s *Server) accessControl(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- s.accessMu.RLock()
- config := s.access
- s.accessMu.RUnlock()
- address := config.clientIP(r)
- if config.allowed(address) {
- next.ServeHTTP(w, r)
- return
- }
- s.logger.Warn(
- "request denied by network access policy",
- "remote_addr", r.RemoteAddr,
- "client_ip", address.String(),
- "path", r.URL.Path,
- )
- writeError(
- w,
- http.StatusForbidden,
- "network_access_denied",
- "access is restricted to internal network addresses",
- )
- })
- }
- func (s *Server) currentAccessConfig() parsedAccessConfig {
- s.accessMu.RLock()
- defer s.accessMu.RUnlock()
- return s.access
- }
- // loadAccessConfig reads the persisted policy (defaulting to internal) into the
- // runtime cache. Called at startup.
- func (s *Server) loadAccessConfig(ctx context.Context) {
- config := defaultAccessConfig()
- setting, err := s.store.AppSetting(ctx, accessSettingKey)
- if err == nil {
- var stored accessConfig
- if json.Unmarshal(setting.Value, &stored) == nil {
- if parsed, parseErr := parseAccessConfig(stored); parseErr == nil {
- config = parsed
- }
- }
- } else if !errors.Is(err, store.ErrNotFound) {
- s.logger.Warn("load access policy failed", "error", err)
- }
- s.accessMu.Lock()
- s.access = config
- s.accessMu.Unlock()
- }
- // handleSecuritySettings reads and writes the network access policy.
- //
- // GET /api/settings/security
- // PUT /api/settings/security
- func (s *Server) handleSecuritySettings(w http.ResponseWriter, r *http.Request) {
- switch r.Method {
- case http.MethodGet:
- config := s.currentAccessConfig()
- address := config.clientIP(r)
- cidrs := make([]string, 0, len(config.cidrs))
- for _, prefix := range config.cidrs {
- cidrs = append(cidrs, prefix.String())
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "mode": config.mode,
- "allowed_cidrs": cidrs,
- "trust_proxy_headers": config.trustProxy,
- "client_ip": address.String(),
- "client_allowed": config.allowed(address),
- },
- })
- case http.MethodPut:
- var request accessConfig
- if err := s.decodeJSON(w, r, &request); err != nil {
- writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
- return
- }
- parsed, err := parseAccessConfig(request)
- if err != nil {
- writeError(w, http.StatusBadRequest, "invalid_access_policy", err.Error())
- return
- }
- payload, err := json.Marshal(request)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
- return
- }
- if err := s.store.UpsertAppSetting(r.Context(), store.AppSetting{
- Key: accessSettingKey,
- Value: payload,
- }); err != nil {
- s.writeStoreError(w, err)
- return
- }
- s.accessMu.Lock()
- s.access = parsed
- s.accessMu.Unlock()
- s.audit(r, "settings.security.update", "settings", "security", "success")
- address := parsed.clientIP(r)
- cidrs := make([]string, 0, len(parsed.cidrs))
- for _, prefix := range parsed.cidrs {
- cidrs = append(cidrs, prefix.String())
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "data": map[string]any{
- "mode": parsed.mode,
- "allowed_cidrs": cidrs,
- "trust_proxy_headers": parsed.trustProxy,
- "client_ip": address.String(),
- "client_allowed": parsed.allowed(address),
- },
- })
- default:
- w.Header().Set("Allow", "GET, PUT")
- writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
- }
- }
|