client/web: add localapi proxy
Adds proxy to the localapi from /api/local/ web client endpoint. The localapi proxy is restricted to an allowlist of those actually used by the web client frontend. Updates tailscale/corp#13775 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:

committed by
Sonia Appasamy

parent
c919ff540f
commit
da6eb076aa
@ -18,11 +18,13 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/client/tailscale/apitype"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
@ -251,8 +253,8 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
||||
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 path {
|
||||
case "/data":
|
||||
switch {
|
||||
case path == "/data":
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
s.serveGetNodeDataJSON(w, r)
|
||||
@ -262,6 +264,9 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
return
|
||||
case strings.HasPrefix(path, "/local/"):
|
||||
s.proxyRequestToLocalAPI(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
}
|
||||
@ -464,6 +469,61 @@ func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData
|
||||
}
|
||||
}
|
||||
|
||||
// proxyRequestToLocalAPI proxies the web API request to the localapi.
|
||||
//
|
||||
// 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 !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)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to construct request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Make request to tailscaled localapi.
|
||||
resp, err := s.lc.DoLocalRequest(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), resp.StatusCode)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Send response back to web frontend.
|
||||
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// TODO(sonia,will): Shouldn't expand this beyond the existing
|
||||
// localapi endpoints until the larger web client auth story
|
||||
// is worked out (tailscale/corp#14335).
|
||||
var localapiAllowlist = []string{
|
||||
"/v0/logout",
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
Reference in New Issue
Block a user