|
|
@@ -445,6 +445,183 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+type apiHandler[data any] struct {
|
|
|
+ s *Server
|
|
|
+ w http.ResponseWriter
|
|
|
+ r *http.Request
|
|
|
+
|
|
|
+ // permissionCheck allows for defining whether a requesting peer's
|
|
|
+ // capabilities grant them access to make the given data update.
|
|
|
+ // If permissionCheck reports false, the request fails as unauthorized.
|
|
|
+ permissionCheck func(data data, peer peerCapabilities) bool
|
|
|
+}
|
|
|
+
|
|
|
+// newHandler constructs a new api handler which restricts the given request
|
|
|
+// to the specified permission check. If the permission check fails for
|
|
|
+// the peer associated with the request, an unauthorized error is returned
|
|
|
+// to the client.
|
|
|
+func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] {
|
|
|
+ return &apiHandler[data]{
|
|
|
+ s: s,
|
|
|
+ w: w,
|
|
|
+ r: r,
|
|
|
+ permissionCheck: permissionCheck,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// alwaysAllowed can be passed as the permissionCheck argument to newHandler
|
|
|
+// for requests that are always allowed to complete regardless of a peer's
|
|
|
+// capabilities.
|
|
|
+func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
|
|
|
+
|
|
|
+func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
|
|
|
+ // TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
|
|
|
+ // WhoIs when originally checking for a session from authorizeRequest.
|
|
|
+ // Would be nice if we could pipe those through to here so we don't end
|
|
|
+ // up having to re-call them to grab the peer capabilities.
|
|
|
+ status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ peer, err := toPeerCapabilities(status, whois)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ return peer, nil
|
|
|
+}
|
|
|
+
|
|
|
+type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
|
|
|
+
|
|
|
+// handle runs the given handler if the source peer satisfies the
|
|
|
+// constraints for running this request.
|
|
|
+//
|
|
|
+// handle is expected for use when `data` type is empty, or set to
|
|
|
+// `noBodyData` in practice. For requests that expect JSON body data
|
|
|
+// to be attached, use handleJSON instead.
|
|
|
+func (a *apiHandler[data]) handle(h http.HandlerFunc) {
|
|
|
+ peer, err := a.getPeer()
|
|
|
+ if err != nil {
|
|
|
+ http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ var body data // not used
|
|
|
+ if !a.permissionCheck(body, peer) {
|
|
|
+ http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ h(a.w, a.r)
|
|
|
+}
|
|
|
+
|
|
|
+// handleJSON manages decoding the request's body JSON and passing
|
|
|
+// it on to the provided function if the source peer satisfies the
|
|
|
+// constraints for running this request.
|
|
|
+func (a *apiHandler[data]) handleJSON(h func(ctx context.Context, data data) error) {
|
|
|
+ defer a.r.Body.Close()
|
|
|
+ var body data
|
|
|
+ if err := json.NewDecoder(a.r.Body).Decode(&body); err != nil {
|
|
|
+ http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ peer, err := a.getPeer()
|
|
|
+ if err != nil {
|
|
|
+ http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if !a.permissionCheck(body, peer) {
|
|
|
+ http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := h(a.r.Context(), body); err != nil {
|
|
|
+ http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ a.w.WriteHeader(http.StatusOK)
|
|
|
+}
|
|
|
+
|
|
|
+// serveAPI serves requests for the web client api.
|
|
|
+// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
|
|
+// which protects the handler using gorilla csrf.
|
|
|
+func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
|
|
+ if r.Method == httpm.PATCH {
|
|
|
+ // Enforce that PATCH requests are always application/json.
|
|
|
+ if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
|
|
+ http.Error(w, "invalid request", http.StatusBadRequest)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
|
|
+ path := strings.TrimPrefix(r.URL.Path, "/api")
|
|
|
+ switch {
|
|
|
+ case path == "/data" && r.Method == httpm.GET:
|
|
|
+ newHandler[noBodyData](s, w, r, alwaysAllowed).
|
|
|
+ handle(s.serveGetNodeData)
|
|
|
+ return
|
|
|
+ case path == "/exit-nodes" && r.Method == httpm.GET:
|
|
|
+ newHandler[noBodyData](s, w, r, alwaysAllowed).
|
|
|
+ handle(s.serveGetExitNodes)
|
|
|
+ return
|
|
|
+ case path == "/routes" && r.Method == httpm.POST:
|
|
|
+ peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
|
|
|
+ if d.SetExitNode && !p.canEdit(capFeatureExitNode) {
|
|
|
+ return false
|
|
|
+ } else if d.SetRoutes && !p.canEdit(capFeatureSubnet) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ newHandler[postRoutesRequest](s, w, r, peerAllowed).
|
|
|
+ handleJSON(s.servePostRoutes)
|
|
|
+ return
|
|
|
+ case path == "/device-details-click" && r.Method == httpm.POST:
|
|
|
+ newHandler[noBodyData](s, w, r, alwaysAllowed).
|
|
|
+ handle(s.serveDeviceDetailsClick)
|
|
|
+ return
|
|
|
+ case path == "/local/v0/logout" && r.Method == httpm.POST:
|
|
|
+ peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
|
|
+ return peer.canEdit(capFeatureAccount)
|
|
|
+ }
|
|
|
+ newHandler[noBodyData](s, w, r, peerAllowed).
|
|
|
+ handle(s.proxyRequestToLocalAPI)
|
|
|
+ return
|
|
|
+ case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
|
|
|
+ peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool {
|
|
|
+ if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ newHandler[maskedPrefs](s, w, r, peerAllowed).
|
|
|
+ handleJSON(s.serveUpdatePrefs)
|
|
|
+ return
|
|
|
+ case path == "/local/v0/update/check" && r.Method == httpm.GET:
|
|
|
+ newHandler[noBodyData](s, w, r, alwaysAllowed).
|
|
|
+ handle(s.proxyRequestToLocalAPI)
|
|
|
+ return
|
|
|
+ case path == "/local/v0/update/check" && r.Method == httpm.POST:
|
|
|
+ peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
|
|
+ return peer.canEdit(capFeatureAccount)
|
|
|
+ }
|
|
|
+ newHandler[noBodyData](s, w, r, peerAllowed).
|
|
|
+ handle(s.proxyRequestToLocalAPI)
|
|
|
+ return
|
|
|
+ case path == "/local/v0/update/progress" && r.Method == httpm.POST:
|
|
|
+ newHandler[noBodyData](s, w, r, alwaysAllowed).
|
|
|
+ handle(s.proxyRequestToLocalAPI)
|
|
|
+ return
|
|
|
+ case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
|
|
|
+ newHandler[noBodyData](s, w, r, alwaysAllowed).
|
|
|
+ handle(s.proxyRequestToLocalAPI)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ http.Error(w, "invalid endpoint", http.StatusNotFound)
|
|
|
+}
|
|
|
+
|
|
|
type authType string
|
|
|
|
|
|
var (
|
|
|
@@ -618,32 +795,6 @@ func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// serveAPI serves requests for the web client api.
|
|
|
-// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
|
|
-// which protects the handler using gorilla csrf.
|
|
|
-func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
|
|
- w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
|
|
- path := strings.TrimPrefix(r.URL.Path, "/api")
|
|
|
- switch {
|
|
|
- case path == "/data" && r.Method == httpm.GET:
|
|
|
- s.serveGetNodeData(w, r)
|
|
|
- return
|
|
|
- case path == "/exit-nodes" && r.Method == httpm.GET:
|
|
|
- s.serveGetExitNodes(w, r)
|
|
|
- return
|
|
|
- case path == "/routes" && r.Method == httpm.POST:
|
|
|
- s.servePostRoutes(w, r)
|
|
|
- return
|
|
|
- case path == "/device-details-click" && r.Method == httpm.POST:
|
|
|
- s.serveDeviceDetailsClick(w, r)
|
|
|
- return
|
|
|
- case strings.HasPrefix(path, "/local/"):
|
|
|
- s.proxyRequestToLocalAPI(w, r)
|
|
|
- return
|
|
|
- }
|
|
|
- http.Error(w, "invalid endpoint", http.StatusNotFound)
|
|
|
-}
|
|
|
-
|
|
|
type nodeData struct {
|
|
|
ID tailcfg.StableNodeID
|
|
|
Status string
|
|
|
@@ -880,6 +1031,23 @@ func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
|
|
|
writeJSON(w, exitNodes)
|
|
|
}
|
|
|
|
|
|
+// maskedPrefs is the subset of ipn.MaskedPrefs that are
|
|
|
+// allowed to be editable via the web UI.
|
|
|
+type maskedPrefs struct {
|
|
|
+ RunSSHSet bool
|
|
|
+ RunSSH bool
|
|
|
+}
|
|
|
+
|
|
|
+func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
|
|
|
+ _, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
|
|
+ RunSSHSet: prefs.RunSSHSet,
|
|
|
+ Prefs: ipn.Prefs{
|
|
|
+ RunSSH: prefs.RunSSH,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ return err
|
|
|
+}
|
|
|
+
|
|
|
type postRoutesRequest struct {
|
|
|
SetExitNode bool // when set, UseExitNode and AdvertiseExitNode values are applied
|
|
|
SetRoutes bool // when set, AdvertiseRoutes value is applied
|
|
|
@@ -888,18 +1056,10 @@ type postRoutesRequest struct {
|
|
|
AdvertiseRoutes []string
|
|
|
}
|
|
|
|
|
|
-func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
|
|
- defer r.Body.Close()
|
|
|
-
|
|
|
- var data postRoutesRequest
|
|
|
- if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
|
|
- http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
- return
|
|
|
- }
|
|
|
- prefs, err := s.lc.GetPrefs(r.Context())
|
|
|
+func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
|
|
|
+ prefs, err := s.lc.GetPrefs(ctx)
|
|
|
if err != nil {
|
|
|
- http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
- return
|
|
|
+ return err
|
|
|
}
|
|
|
var currNonExitRoutes []string
|
|
|
var currAdvertisingExitNode bool
|
|
|
@@ -922,8 +1082,7 @@ func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
|
|
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
|
|
routes, err := netutil.CalcAdvertiseRoutes(routesStr, data.AdvertiseExitNode)
|
|
|
if err != nil {
|
|
|
- http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
- return
|
|
|
+ return err
|
|
|
}
|
|
|
|
|
|
hasExitNodeRoute := func(all []netip.Prefix) bool {
|
|
|
@@ -932,8 +1091,7 @@ func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
|
|
}
|
|
|
|
|
|
if !data.UseExitNode.IsZero() && hasExitNodeRoute(routes) {
|
|
|
- http.Error(w, "cannot use and advertise exit node at same time", http.StatusBadRequest)
|
|
|
- return
|
|
|
+ return errors.New("cannot use and advertise exit node at same time")
|
|
|
}
|
|
|
|
|
|
// Make prefs update.
|
|
|
@@ -945,12 +1103,8 @@ func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
|
|
AdvertiseRoutes: routes,
|
|
|
},
|
|
|
}
|
|
|
- if _, err := s.lc.EditPrefs(r.Context(), p); err != nil {
|
|
|
- http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- w.WriteHeader(http.StatusOK)
|
|
|
+ _, err = s.lc.EditPrefs(ctx, p)
|
|
|
+ return err
|
|
|
}
|
|
|
|
|
|
// tailscaleUp starts the daemon with the provided options.
|
|
|
@@ -1089,26 +1243,12 @@ func (s *Server) serveDeviceDetailsClick(w http.ResponseWriter, r *http.Request)
|
|
|
//
|
|
|
// The web API request path is expected to exactly match a localapi path,
|
|
|
// with prefix /api/local/ rather than /localapi/.
|
|
|
-//
|
|
|
-// If the localapi path is not included in localapiAllowlist,
|
|
|
-// the request is rejected.
|
|
|
func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request) {
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/local")
|
|
|
if r.URL.Path == path { // missing prefix
|
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
|
return
|
|
|
}
|
|
|
- if r.Method == httpm.PATCH {
|
|
|
- // enforce that PATCH requests are always application/json
|
|
|
- if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
|
|
- http.Error(w, "invalid request", http.StatusBadRequest)
|
|
|
- return
|
|
|
- }
|
|
|
- }
|
|
|
- if !slices.Contains(localapiAllowlist, path) {
|
|
|
- http.Error(w, fmt.Sprintf("%s not allowed from localapi proxy", path), http.StatusForbidden)
|
|
|
- return
|
|
|
- }
|
|
|
|
|
|
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
|
|
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
|
|
|
@@ -1133,21 +1273,6 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// localapiAllowlist is an allowlist of localapi endpoints the
|
|
|
-// web client is allowed to proxy to the client's localapi.
|
|
|
-//
|
|
|
-// Rather than exposing all localapi endpoints over the proxy,
|
|
|
-// this limits to just the ones actually used from the web
|
|
|
-// client frontend.
|
|
|
-var localapiAllowlist = []string{
|
|
|
- "/v0/logout",
|
|
|
- "/v0/prefs",
|
|
|
- "/v0/update/check",
|
|
|
- "/v0/update/install",
|
|
|
- "/v0/update/progress",
|
|
|
- "/v0/upload-client-metrics",
|
|
|
-}
|
|
|
-
|
|
|
// csrfKey returns a key that can be used for CSRF protection.
|
|
|
// If an error occurs during key creation, the error is logged and the active process terminated.
|
|
|
// If the server is running in CGI mode, the key is cached to disk and reused between requests.
|