This commit is contained in:
bluepython508 committed 2026-10-22 12:27:19 +01:00
1 parent 3530afb854
commit 6903fcff27
2146 files changed
+112256 -190703

No files matched your search

+93 -121
View File
@@ -1,4 +1,4 @@
// Copyright (c) Tailscale Inc & AUTHORS
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// Package web provides the Tailscale client for web.
@@ -35,8 +35,10 @@ import (
"tailscale.com/net/netutil"
"tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"tailscale.com/tsweb"
"tailscale.com/types/logger"
"tailscale.com/types/views"
"tailscale.com/util/ctxkey"
"tailscale.com/util/httpm"
"tailscale.com/util/syspolicy/policyclient"
"tailscale.com/version"
@@ -527,45 +529,40 @@ 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,
// handleJSON manages decoding the request's body JSON as data and passing it
// on to the provided handler function.
func handleJSON[data any](h func(ctx context.Context, data data) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var body data
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := h(r.Context(), body); err != nil {
if httpErr, ok := errors.AsType[tsweb.HTTPError](err); ok {
tsweb.WriteHTTPError(w, r, httpErr)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusOK)
}
}
// 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 }
var contextKeyPeer = ctxkey.New("peer-capabilities", peerCapabilities{})
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
func (s *Server) setPeer(r *http.Request) (*http.Request, 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())
status, err := s.lc.StatusWithoutPeers(r.Context())
if err != nil {
return nil, err
}
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
if err != nil {
return nil, err
}
@@ -573,56 +570,11 @@ func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
if err != nil {
return nil, err
}
return peer, nil
return r.WithContext(contextKeyPeer.WithValue(r.Context(), 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)
func (s *Server) getPeer(ctx context.Context) peerCapabilities {
return contextKeyPeer.Value(ctx)
}
// serveAPI serves requests for the web client api.
@@ -637,67 +589,44 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
}
}
var err error
r, err = s.setPeer(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api")
switch {
case path == "/data" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveGetNodeData)
s.serveGetNodeData(w, r)
return
case path == "/exit-nodes" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveGetExitNodes)
s.serveGetExitNodes(w, r)
return
case path == "/routes" && r.Method == httpm.POST:
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
return false
} else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
return false
}
return true
}
newHandler[postRoutesRequest](s, w, r, peerAllowed).
handleJSON(s.servePostRoutes)
handleJSON[postRoutesRequest](s.servePostRoutes)(w, r)
return
case path == "/device-details-click" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.serveDeviceDetailsClick)
s.serveDeviceDetailsClick(w, r)
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)
s.proxyRequestToLocalAPI(w, r)
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)
handleJSON[maskedPrefs](s.serveUpdatePrefs)(w, r)
return
case path == "/local/v0/update/check" && r.Method == httpm.GET:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
s.proxyRequestToLocalAPI(w, r)
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)
s.proxyRequestToLocalAPI(w, r)
return
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
s.proxyRequestToLocalAPI(w, r)
return
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
newHandler[noBodyData](s, w, r, alwaysAllowed).
handle(s.proxyRequestToLocalAPI)
s.proxyRequestToLocalAPI(w, r)
return
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
@@ -771,6 +700,19 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
}
}
// We might have a session for which we haven't awaited the result yet.
// This can happen when the AuthURL opens in the same browser tab instead
// of a new one due to browser settings.
// (See https://github.com/tailscale/tailscale/issues/11905)
// We therefore set a PendingAuth flag when creating a new session, check
// it here and call awaitUserAuth if we find it to be true. Once the auth
// wait completes, awaitUserAuth will set PendingAuth to false.
if sErr == nil && session.PendingAuth == true {
if err := s.awaitUserAuth(r.Context(), session); err != nil {
sErr = err
}
}
switch {
case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
@@ -1109,6 +1051,11 @@ type maskedPrefs struct {
}
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
peer := s.getPeer(ctx)
if prefs.RunSSHSet && !peer.canEdit(capFeatureSSH) {
return tsweb.Error(http.StatusUnauthorized, "RunSSHSet not allowed", nil)
}
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
RunSSHSet: prefs.RunSSHSet,
Prefs: ipn.Prefs{
@@ -1127,6 +1074,17 @@ type postRoutesRequest struct {
}
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
if !data.SetExitNode && !data.SetRoutes {
return tsweb.Error(http.StatusBadRequest, "must specify SetExitNode or SetRoutes", nil)
}
peer := s.getPeer(ctx)
if data.SetExitNode && !peer.canEdit(capFeatureExitNodes) {
return tsweb.Error(http.StatusUnauthorized, "SetExitNode not allowed", nil)
}
if data.SetRoutes && !peer.canEdit(capFeatureSubnets) {
return tsweb.Error(http.StatusUnauthorized, "SetRoutes not allowed", nil)
}
prefs, err := s.lc.GetPrefs(ctx)
if err != nil {
return err
@@ -1140,13 +1098,14 @@ func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) er
}
currNonExitRoutes = append(currNonExitRoutes, r.String())
}
// Set non-edited fields to their current values.
if data.SetExitNode {
data.AdvertiseRoutes = currNonExitRoutes
} else if data.SetRoutes {
// For each group of fields not being set, preserve the current prefs.
if !data.SetExitNode {
data.AdvertiseExitNode = currAdvertisingExitNode
data.UseExitNode = prefs.ExitNodeID
}
if !data.SetRoutes {
data.AdvertiseRoutes = currNonExitRoutes
}
// Calculate routes.
routesStr := strings.Join(data.AdvertiseRoutes, ",")
@@ -1323,6 +1282,19 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
return
}
switch path {
case "/v0/logout":
if !s.getPeer(r.Context()).canEdit(capFeatureAccount) {
http.Error(w, "not allowed", http.StatusUnauthorized)
return
}
case "/v0/update/check":
if r.Method == httpm.POST && !s.getPeer(r.Context()).canEdit(capFeatureAccount) {
http.Error(w, "not allowed", http.StatusUnauthorized)
return
}
}
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
if err != nil {