access_control.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. package server
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "net"
  7. "net/http"
  8. "net/netip"
  9. "strings"
  10. "vocat/internal/store"
  11. )
  12. const accessSettingKey = "security.access"
  13. // accessConfig is the persisted network access policy.
  14. type accessConfig struct {
  15. Mode string `json:"mode"` // "internal" (default) or "public"
  16. AllowedCIDRs []string `json:"allowed_cidrs"` // extra CIDRs always allowed
  17. TrustProxyHeaders bool `json:"trust_proxy_headers"` // honor X-Forwarded-For
  18. }
  19. // parsedAccessConfig is the validated runtime form of accessConfig.
  20. type parsedAccessConfig struct {
  21. mode string
  22. cidrs []netip.Prefix
  23. trustProxy bool
  24. }
  25. // internalNetworks are always allowed when mode is "internal": loopback,
  26. // RFC1918 private ranges, link-local, and IPv6 ULA.
  27. var internalNetworks = []netip.Prefix{
  28. netip.MustParsePrefix("127.0.0.0/8"),
  29. netip.MustParsePrefix("10.0.0.0/8"),
  30. netip.MustParsePrefix("172.16.0.0/12"),
  31. netip.MustParsePrefix("192.168.0.0/16"),
  32. netip.MustParsePrefix("169.254.0.0/16"),
  33. netip.MustParsePrefix("::1/128"),
  34. netip.MustParsePrefix("fe80::/10"),
  35. netip.MustParsePrefix("fc00::/7"),
  36. }
  37. func defaultAccessConfig() parsedAccessConfig {
  38. return parsedAccessConfig{mode: "internal"}
  39. }
  40. // parseAccessConfig validates and parses a persisted access policy.
  41. func parseAccessConfig(config accessConfig) (parsedAccessConfig, error) {
  42. mode := strings.ToLower(strings.TrimSpace(config.Mode))
  43. if mode == "" {
  44. mode = "internal"
  45. }
  46. if mode != "internal" && mode != "public" {
  47. return parsedAccessConfig{}, errors.New("mode must be \"internal\" or \"public\"")
  48. }
  49. parsed := parsedAccessConfig{
  50. mode: mode,
  51. trustProxy: config.TrustProxyHeaders,
  52. }
  53. for _, raw := range config.AllowedCIDRs {
  54. raw = strings.TrimSpace(raw)
  55. if raw == "" {
  56. continue
  57. }
  58. if prefix, err := netip.ParsePrefix(raw); err == nil {
  59. parsed.cidrs = append(parsed.cidrs, prefix.Masked())
  60. continue
  61. }
  62. if address, err := netip.ParseAddr(raw); err == nil {
  63. bits := 32
  64. if address.Is6() {
  65. bits = 128
  66. }
  67. parsed.cidrs = append(parsed.cidrs, netip.PrefixFrom(address, bits))
  68. continue
  69. }
  70. return parsedAccessConfig{}, errors.New("invalid CIDR or IP: " + raw)
  71. }
  72. return parsed, nil
  73. }
  74. // allowed reports whether a client address may reach the service.
  75. func (config parsedAccessConfig) allowed(address netip.Addr) bool {
  76. if !address.IsValid() {
  77. return false
  78. }
  79. // Normalize IPv4-mapped IPv6 addresses (e.g. ::ffff:192.168.1.5 seen on
  80. // dual-stack listeners) to their IPv4 form so they match the internal
  81. // ranges below; without this they would be denied even though they are
  82. // ordinary internal IPv4 clients.
  83. address = address.Unmap()
  84. if config.mode == "public" {
  85. return true
  86. }
  87. if address.IsLoopback() {
  88. return true
  89. }
  90. for _, prefix := range internalNetworks {
  91. if prefix.Contains(address) {
  92. return true
  93. }
  94. }
  95. for _, prefix := range config.cidrs {
  96. if prefix.Contains(address) {
  97. return true
  98. }
  99. }
  100. return false
  101. }
  102. // clientIP determines the request's source address, honoring X-Forwarded-For
  103. // only when the deployment is configured to trust proxy headers.
  104. func (config parsedAccessConfig) clientIP(r *http.Request) netip.Addr {
  105. if config.trustProxy {
  106. if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
  107. first := strings.TrimSpace(strings.Split(forwarded, ",")[0])
  108. if address, err := netip.ParseAddr(first); err == nil {
  109. return address.Unmap()
  110. }
  111. }
  112. if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" {
  113. if address, err := netip.ParseAddr(real); err == nil {
  114. return address.Unmap()
  115. }
  116. }
  117. }
  118. host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
  119. if err != nil {
  120. host = strings.TrimSpace(r.RemoteAddr)
  121. }
  122. address, err := netip.ParseAddr(host)
  123. if err != nil {
  124. return netip.Addr{}
  125. }
  126. // Report the canonical (unmapped) form so logs, the login rate-limit key,
  127. // and the access decision all agree on one representation of an IPv4 client.
  128. return address.Unmap()
  129. }
  130. // accessControl rejects requests whose source IP is outside the configured
  131. // access policy. It wraps the whole mux so every route (API, SPA, websheets) is
  132. // protected uniformly.
  133. func (s *Server) accessControl(next http.Handler) http.Handler {
  134. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  135. s.accessMu.RLock()
  136. config := s.access
  137. s.accessMu.RUnlock()
  138. address := config.clientIP(r)
  139. if config.allowed(address) {
  140. next.ServeHTTP(w, r)
  141. return
  142. }
  143. s.logger.Warn(
  144. "request denied by network access policy",
  145. "remote_addr", r.RemoteAddr,
  146. "client_ip", address.String(),
  147. "path", r.URL.Path,
  148. )
  149. writeError(
  150. w,
  151. http.StatusForbidden,
  152. "network_access_denied",
  153. "access is restricted to internal network addresses",
  154. )
  155. })
  156. }
  157. func (s *Server) currentAccessConfig() parsedAccessConfig {
  158. s.accessMu.RLock()
  159. defer s.accessMu.RUnlock()
  160. return s.access
  161. }
  162. // loadAccessConfig reads the persisted policy (defaulting to internal) into the
  163. // runtime cache. Called at startup.
  164. func (s *Server) loadAccessConfig(ctx context.Context) {
  165. config := defaultAccessConfig()
  166. setting, err := s.store.AppSetting(ctx, accessSettingKey)
  167. if err == nil {
  168. var stored accessConfig
  169. if json.Unmarshal(setting.Value, &stored) == nil {
  170. if parsed, parseErr := parseAccessConfig(stored); parseErr == nil {
  171. config = parsed
  172. }
  173. }
  174. } else if !errors.Is(err, store.ErrNotFound) {
  175. s.logger.Warn("load access policy failed", "error", err)
  176. }
  177. s.accessMu.Lock()
  178. s.access = config
  179. s.accessMu.Unlock()
  180. }
  181. // handleSecuritySettings reads and writes the network access policy.
  182. //
  183. // GET /api/settings/security
  184. // PUT /api/settings/security
  185. func (s *Server) handleSecuritySettings(w http.ResponseWriter, r *http.Request) {
  186. switch r.Method {
  187. case http.MethodGet:
  188. config := s.currentAccessConfig()
  189. address := config.clientIP(r)
  190. cidrs := make([]string, 0, len(config.cidrs))
  191. for _, prefix := range config.cidrs {
  192. cidrs = append(cidrs, prefix.String())
  193. }
  194. writeJSON(w, http.StatusOK, map[string]any{
  195. "data": map[string]any{
  196. "mode": config.mode,
  197. "allowed_cidrs": cidrs,
  198. "trust_proxy_headers": config.trustProxy,
  199. "client_ip": address.String(),
  200. "client_allowed": config.allowed(address),
  201. },
  202. })
  203. case http.MethodPut:
  204. var request accessConfig
  205. if err := s.decodeJSON(w, r, &request); err != nil {
  206. writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
  207. return
  208. }
  209. parsed, err := parseAccessConfig(request)
  210. if err != nil {
  211. writeError(w, http.StatusBadRequest, "invalid_access_policy", err.Error())
  212. return
  213. }
  214. payload, err := json.Marshal(request)
  215. if err != nil {
  216. writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
  217. return
  218. }
  219. if err := s.store.UpsertAppSetting(r.Context(), store.AppSetting{
  220. Key: accessSettingKey,
  221. Value: payload,
  222. }); err != nil {
  223. s.writeStoreError(w, err)
  224. return
  225. }
  226. s.accessMu.Lock()
  227. s.access = parsed
  228. s.accessMu.Unlock()
  229. s.audit(r, "settings.security.update", "settings", "security", "success")
  230. address := parsed.clientIP(r)
  231. cidrs := make([]string, 0, len(parsed.cidrs))
  232. for _, prefix := range parsed.cidrs {
  233. cidrs = append(cidrs, prefix.String())
  234. }
  235. writeJSON(w, http.StatusOK, map[string]any{
  236. "data": map[string]any{
  237. "mode": parsed.mode,
  238. "allowed_cidrs": cidrs,
  239. "trust_proxy_headers": parsed.trustProxy,
  240. "client_ip": address.String(),
  241. "client_allowed": parsed.allowed(address),
  242. },
  243. })
  244. default:
  245. w.Header().Set("Allow", "GET, PUT")
  246. writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
  247. }
  248. }