@@ -101,6 +132,7 @@ function ManageView(props: NodeData) {
Tailscale is up and running. You can connect to this device from devices
in your tailnet by using its name or IP address.
+
)
}
diff --git a/client/web/src/hooks/auth.ts b/client/web/src/hooks/auth.ts
new file mode 100644
index 000000000..1fa26aec5
--- /dev/null
+++ b/client/web/src/hooks/auth.ts
@@ -0,0 +1,37 @@
+import { useCallback, useEffect, useState } from "react"
+import { apiFetch } from "src/api"
+
+export type AuthResponse = {
+ ok: boolean
+ authUrl?: string
+}
+
+// useAuth reports and refreshes Tailscale auth status
+// for the web client.
+export default function useAuth() {
+ const [data, setData] = useState()
+ const [loading, setLoading] = useState(false)
+
+ const loadAuth = useCallback((wait?: boolean) => {
+ const url = wait ? "/auth?wait=true" : "/auth"
+ setLoading(true)
+ return apiFetch(url, "GET")
+ .then((r) => r.json())
+ .then((d) => {
+ setLoading(false)
+ setData(d)
+ })
+ .catch((error) => {
+ setLoading(false)
+ console.error(error)
+ })
+ }, [])
+
+ useEffect(() => {
+ loadAuth()
+ }, [])
+
+ const waitOnAuth = useCallback(() => loadAuth(true), [])
+
+ return { data, loading, waitOnAuth }
+}
diff --git a/client/web/web.go b/client/web/web.go
index 93b0a2600..3827f71c7 100644
--- a/client/web/web.go
+++ b/client/web/web.go
@@ -5,8 +5,10 @@
package web
import (
+ "bytes"
"context"
"crypto/rand"
+ "encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -19,6 +21,7 @@ import (
"slices"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/gorilla/csrf"
@@ -58,7 +61,8 @@ type Server struct {
//
// The map provides a lookup of the session by cookie value
// (browserSession.ID => browserSession).
- browserSessions sync.Map
+ browserSessions sync.Map
+ controlServerURL atomic.Value // access through getControlServerURL
}
const (
@@ -77,25 +81,26 @@ type browserSession struct {
// ID is the unique identifier for the session.
// It is passed in the user's "TS-Web-Session" browser cookie.
ID string
- SrcNode tailcfg.StableNodeID
+ SrcNode tailcfg.NodeID
SrcUser tailcfg.UserID
- AuthURL string // control server URL for user to authenticate the session
- Authenticated time.Time // when zero, authentication not complete
+ AuthURL string // control server URL for user to authenticate the session
+ Created time.Time
+ Authenticated bool
}
// isAuthorized reports true if the given session is authorized
// to be used by its associated user to access the full management
// web client.
//
-// isAuthorized is true only when s.Authenticated is non-zero
-// (i.e. the user has authenticated the session) and the session
-// is not expired.
-// 2023-10-05: Sessions expire by default after 30 days.
+// isAuthorized is true only when s.Authenticated is true (i.e.
+// the user has authenticated the session) and the session is not
+// expired.
+// 2023-10-05: Sessions expire by default 30 days after creation.
func (s *browserSession) isAuthorized() bool {
switch {
case s == nil:
return false
- case s.Authenticated.IsZero():
+ case !s.Authenticated:
return false // awaiting auth
case s.isExpired(): // TODO: add time field to server?
return false // expired
@@ -104,20 +109,20 @@ func (s *browserSession) isAuthorized() bool {
}
// isExpired reports true if s is expired.
-// 2023-10-05: Sessions expire by default after 30 days.
-// If s.Authenticated is zero, isExpired reports false.
+// 2023-10-05: Sessions expire by default 30 days after creation.
func (s *browserSession) isExpired() bool {
- return !s.Authenticated.IsZero() && s.Authenticated.Before(time.Now().Add(-sessionCookieExpiry)) // TODO: add time field to server?
+ return !s.Created.IsZero() && time.Now().After(s.expires()) // TODO: add time field to server?
+}
+
+// expires reports when the given session expires.
+func (s *browserSession) expires() time.Time {
+ return s.Created.Add(sessionCookieExpiry)
}
// ServerOpts contains options for constructing a new Server.
type ServerOpts struct {
DevMode bool
- // LoginOnly indicates that the server should only serve the minimal
- // login client and not the full web client.
- LoginOnly bool
-
// CGIMode indicates if the server is running as a CGI script.
CGIMode bool
@@ -223,7 +228,11 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
return true
case strings.HasPrefix(r.URL.Path, "/api/"):
// All other /api/ endpoints require a valid browser session.
- session, err := s.getTailscaleBrowserSession(r)
+ //
+ // TODO(sonia): s.getTailscaleBrowserSession calls whois again,
+ // should try and use the above call instead of running another
+ // localapi request.
+ session, _, err := s.getTailscaleBrowserSession(r)
if err != nil || !session.isAuthorized() {
http.Error(w, "no valid session", http.StatusUnauthorized)
return false
@@ -275,6 +284,7 @@ var (
errNotUsingTailscale = errors.New("not-using-tailscale")
errTaggedSource = errors.New("tagged-source")
errNotOwner = errors.New("not-owner")
+ errFailedAuth = errors.New("failed-auth")
)
// getTailscaleBrowserSession retrieves the browser session associated with
@@ -296,70 +306,122 @@ var (
// If no error is returned, the browserSession is always non-nil.
// getTailscaleBrowserSession does not check whether the session has been
// authorized by the user. Callers can use browserSession.isAuthorized.
-func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, error) {
+//
+// The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
+// unless getTailscaleBrowserSession reports errNotUsingTailscale.
+func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) {
whoIs, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
switch {
case err != nil:
- return nil, errNotUsingTailscale
+ return nil, nil, errNotUsingTailscale
case whoIs.Node.IsTagged():
- return nil, errTaggedSource
+ return nil, whoIs, errTaggedSource
}
- srcNode := whoIs.Node.StableID
+ srcNode := whoIs.Node.ID
srcUser := whoIs.UserProfile.ID
status, err := s.lc.StatusWithoutPeers(r.Context())
switch {
case err != nil:
- return nil, err
+ return nil, whoIs, err
case status.Self == nil:
- return nil, errors.New("missing self node in tailscale status")
+ return nil, whoIs, errors.New("missing self node in tailscale status")
case !status.Self.IsTagged() && status.Self.UserID != srcUser:
- return nil, errNotOwner
+ return nil, whoIs, errNotOwner
}
cookie, err := r.Cookie(sessionCookieName)
if errors.Is(err, http.ErrNoCookie) {
- return nil, errNoSession
+ return nil, whoIs, errNoSession
} else if err != nil {
- return nil, err
+ return nil, whoIs, err
}
v, ok := s.browserSessions.Load(cookie.Value)
if !ok {
- return nil, errNoSession
+ return nil, whoIs, errNoSession
}
session := v.(*browserSession)
if session.SrcNode != srcNode || session.SrcUser != srcUser {
// In this case the browser cookie is associated with another tailscale node.
// Maybe the source browser's machine was logged out and then back in as a different node.
// Return errNoSession because there is no session for this user.
- return nil, errNoSession
+ return nil, whoIs, errNoSession
} else if session.isExpired() {
// Session expired, remove from session map and return errNoSession.
s.browserSessions.Delete(session.ID)
- return nil, errNoSession
+ return nil, whoIs, errNoSession
}
- return session, nil
+ return session, whoIs, nil
}
type authResponse struct {
OK bool `json:"ok"` // true when user has valid auth session
AuthURL string `json:"authUrl,omitempty"` // filled when user has control auth action to take
- Error string `json:"error,omitempty"` // filled when Ok is false
}
func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) {
+ if r.Method != httpm.GET {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
var resp authResponse
- session, err := s.getTailscaleBrowserSession(r)
+ session, whois, err := s.getTailscaleBrowserSession(r)
switch {
case err != nil && !errors.Is(err, errNoSession):
- resp = authResponse{OK: false, Error: err.Error()}
+ http.Error(w, err.Error(), http.StatusUnauthorized)
+ return
case session == nil:
- // TODO(tailscale/corp#14335): Create a new auth path from control,
- // and store back to s.browserSessions and request cookie.
+ // Create a new session.
+ d, err := s.getOrAwaitAuthURL(r.Context(), "", whois.Node.ID)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ sid, err := s.newSessionID()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ session := &browserSession{
+ ID: sid,
+ SrcNode: whois.Node.ID,
+ SrcUser: whois.UserProfile.ID,
+ AuthURL: d.URL,
+ Created: time.Now(),
+ }
+ s.browserSessions.Store(sid, session)
+ // Set the cookie on browser.
+ http.SetCookie(w, &http.Cookie{
+ Name: sessionCookieName,
+ Value: sid,
+ Raw: sid,
+ Path: "/",
+ Expires: session.expires(),
+ })
+ resp = authResponse{OK: false, AuthURL: d.URL}
case !session.isAuthorized():
- // TODO(tailscale/corp#14335): Check on the session auth path status from control,
- // and store back to s.browserSessions.
+ if r.URL.Query().Get("wait") == "true" {
+ // Client requested we block until user completes auth.
+ d, err := s.getOrAwaitAuthURL(r.Context(), session.AuthURL, whois.Node.ID)
+ if errors.Is(err, errFailedAuth) {
+ http.Error(w, "user is unauthorized", http.StatusUnauthorized)
+ s.browserSessions.Delete(session.ID) // clean up the failed session
+ return
+ } else if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if d.Complete {
+ session.Authenticated = d.Complete
+ s.browserSessions.Store(session.ID, session)
+ }
+ }
+ if session.isAuthorized() {
+ resp = authResponse{OK: true}
+ } else {
+ resp = authResponse{OK: false, AuthURL: session.AuthURL}
+ }
default:
resp = authResponse{OK: true}
}
@@ -371,6 +433,84 @@ func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
}
+func (s *Server) newSessionID() (string, error) {
+ raw := make([]byte, 16)
+ for i := 0; i < 5; i++ {
+ if _, err := rand.Read(raw); err != nil {
+ return "", err
+ }
+ cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw)
+ if _, ok := s.browserSessions.Load(cookie); !ok {
+ return cookie, nil
+ }
+ }
+ return "", errors.New("too many collisions generating new session; please refresh page")
+}
+
+func (s *Server) getControlServerURL(ctx context.Context) (string, error) {
+ if v := s.controlServerURL.Load(); v != nil {
+ v, _ := v.(string)
+ return v, nil
+ }
+ prefs, err := s.lc.GetPrefs(ctx)
+ if err != nil {
+ return "", err
+ }
+ url := prefs.ControlURLOrDefault()
+ s.controlServerURL.Store(url)
+ return url, nil
+}
+
+// getOrAwaitAuthURL connects to the control server for user auth,
+// with the following behavior:
+//
+// 1. If authURL is provided empty, a new auth URL is created on the
+// control server and reported back here, which can then be used
+// to redirect the user on the frontend.
+// 2. If authURL is provided non-empty, the connection to control
+// blocks until the user has completed the URL. getOrAwaitAuthURL
+// terminates when either the URL is completed, or ctx is canceled.
+func (s *Server) getOrAwaitAuthURL(ctx context.Context, authURL string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) {
+ serverURL, err := s.getControlServerURL(ctx)
+ if err != nil {
+ return nil, err
+ }
+ type data struct {
+ ID string
+ Src tailcfg.NodeID
+ }
+ var b bytes.Buffer
+ if err := json.NewEncoder(&b).Encode(data{
+ ID: strings.TrimPrefix(authURL, serverURL),
+ Src: src,
+ }); err != nil {
+ return nil, err
+ }
+ url := "http://" + apitype.LocalAPIHost + "/localapi/v0/debug-web-client"
+ req, err := http.NewRequestWithContext(ctx, "POST", url, &b)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := s.lc.DoLocalRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode == http.StatusUnauthorized {
+ // User completed auth, but control server reported
+ // them unauthorized to manage this node.
+ return nil, errFailedAuth
+ } else if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("failed request: %s", body)
+ }
+ var authResp *tailcfg.WebClientAuthResponse
+ if err := json.Unmarshal(body, &authResp); err != nil {
+ return nil, err
+ }
+ return authResp, nil
+}
+
// 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.
diff --git a/client/web/web_test.go b/client/web/web_test.go
index b44aeb4ef..60fd29287 100644
--- a/client/web/web_test.go
+++ b/client/web/web_test.go
@@ -11,6 +11,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "reflect"
"strings"
"testing"
"time"
@@ -18,6 +19,7 @@ import (
"github.com/google/go-cmp/cmp"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
+ "tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/memnet"
"tailscale.com/tailcfg"
@@ -151,15 +153,15 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
tags := views.SliceOf([]string{"tag:server"})
tailnetNodes := map[string]*apitype.WhoIsResponse{
userANodeIP: {
- Node: &tailcfg.Node{StableID: "Node1"},
+ Node: &tailcfg.Node{ID: 1},
UserProfile: userA,
},
userBNodeIP: {
- Node: &tailcfg.Node{StableID: "Node2"},
+ Node: &tailcfg.Node{ID: 2},
UserProfile: userB,
},
taggedNodeIP: {
- Node: &tailcfg.Node{StableID: "Node3", Tags: tags.AsSlice()},
+ Node: &tailcfg.Node{ID: 3, Tags: tags.AsSlice()},
},
}
@@ -174,21 +176,24 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
// Add some browser sessions to cache state.
userASession := &browserSession{
ID: "cookie1",
- SrcNode: "Node1",
+ SrcNode: 1,
SrcUser: userA.ID,
- Authenticated: time.Time{}, // not yet authenticated
+ Created: time.Now(),
+ Authenticated: false, // not yet authenticated
}
userBSession := &browserSession{
ID: "cookie2",
- SrcNode: "Node2",
+ SrcNode: 2,
SrcUser: userB.ID,
- Authenticated: time.Now().Add(-2 * sessionCookieExpiry), // expired
+ Created: time.Now().Add(-2 * sessionCookieExpiry),
+ Authenticated: true, // expired
}
userASessionAuthorized := &browserSession{
ID: "cookie3",
- SrcNode: "Node1",
+ SrcNode: 1,
SrcUser: userA.ID,
- Authenticated: time.Now(), // authenticated and not expired
+ Created: time.Now(),
+ Authenticated: true, // authenticated and not expired
}
s.browserSessions.Store(userASession.ID, userASession)
s.browserSessions.Store(userBSession.ID, userBSession)
@@ -281,7 +286,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) {
if tt.cookie != "" {
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
}
- session, err := s.getTailscaleBrowserSession(r)
+ session, _, err := s.getTailscaleBrowserSession(r)
if !errors.Is(err, tt.wantError) {
t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err)
}
@@ -322,9 +327,10 @@ func TestAuthorizeRequest(t *testing.T) {
validCookie := "ts-cookie"
s.browserSessions.Store(validCookie, &browserSession{
ID: validCookie,
- SrcNode: remoteNode.Node.StableID,
+ SrcNode: remoteNode.Node.ID,
SrcUser: user.ID,
- Authenticated: time.Now(),
+ Created: time.Now(),
+ Authenticated: true,
})
tests := []struct {
@@ -391,6 +397,149 @@ func TestAuthorizeRequest(t *testing.T) {
}
}
+func TestServeTailscaleAuth(t *testing.T) {
+ user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)}
+ self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID}
+ remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{ID: 1}, UserProfile: user}
+ remoteIP := "100.100.100.101"
+
+ lal := memnet.Listen("local-tailscaled.sock:80")
+ defer lal.Close()
+ localapi := mockLocalAPI(t,
+ map[string]*apitype.WhoIsResponse{remoteIP: remoteNode},
+ func() *ipnstate.PeerStatus { return self },
+ )
+ defer localapi.Close()
+ go localapi.Serve(lal)
+
+ s := &Server{
+ lc: &tailscale.LocalClient{Dial: lal.Dial},
+ tsDebugMode: "full",
+ }
+
+ successCookie := "ts-cookie-success"
+ s.browserSessions.Store(successCookie, &browserSession{
+ ID: successCookie,
+ SrcNode: remoteNode.Node.ID,
+ SrcUser: user.ID,
+ Created: time.Now(),
+ AuthURL: testControlURL + testAuthPathSuccess,
+ })
+ failureCookie := "ts-cookie-failure"
+ s.browserSessions.Store(failureCookie, &browserSession{
+ ID: failureCookie,
+ SrcNode: remoteNode.Node.ID,
+ SrcUser: user.ID,
+ Created: time.Now(),
+ AuthURL: testControlURL + testAuthPathError,
+ })
+ expiredCookie := "ts-cookie-expired"
+ s.browserSessions.Store(expiredCookie, &browserSession{
+ ID: expiredCookie,
+ SrcNode: remoteNode.Node.ID,
+ SrcUser: user.ID,
+ Created: time.Now().Add(-sessionCookieExpiry * 2),
+ AuthURL: testControlURL + "/a/old-auth-url",
+ })
+
+ tests := []struct {
+ name string
+ cookie string
+ query string
+ wantStatus int
+ wantResp *authResponse
+ wantNewCookie bool // new cookie generated
+ }{
+ {
+ name: "new-session-created",
+ wantStatus: http.StatusOK,
+ wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
+ wantNewCookie: true,
+ }, {
+ name: "query-existing-incomplete-session",
+ cookie: successCookie,
+ wantStatus: http.StatusOK,
+ wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPathSuccess},
+ }, {
+ name: "transition-to-successful-session",
+ cookie: successCookie,
+ // query "wait" indicates the FE wants to make
+ // local api call to wait until session completed.
+ query: "wait=true",
+ wantStatus: http.StatusOK,
+ wantResp: &authResponse{OK: true},
+ }, {
+ name: "query-existing-complete-session",
+ cookie: successCookie,
+ wantStatus: http.StatusOK,
+ wantResp: &authResponse{OK: true},
+ }, {
+ name: "transition-to-failed-session",
+ cookie: failureCookie,
+ query: "wait=true",
+ wantStatus: http.StatusUnauthorized,
+ wantResp: nil,
+ }, {
+ name: "failed-session-cleaned-up",
+ cookie: failureCookie,
+ wantStatus: http.StatusOK,
+ wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
+ wantNewCookie: true,
+ }, {
+ name: "expired-cookie-gets-new-session",
+ cookie: expiredCookie,
+ wantStatus: http.StatusOK,
+ wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath},
+ wantNewCookie: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := httptest.NewRequest("GET", "/api/auth", nil)
+ r.URL.RawQuery = tt.query
+ r.RemoteAddr = remoteIP
+ r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
+ w := httptest.NewRecorder()
+ s.serveTailscaleAuth(w, r)
+ res := w.Result()
+ defer res.Body.Close()
+ if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
+ t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus)
+ }
+ var gotResp *authResponse
+ if res.StatusCode == http.StatusOK {
+ body, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal(body, &gotResp); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if !reflect.DeepEqual(gotResp, tt.wantResp) {
+ t.Errorf("wrong response; want=%v, got=%v", tt.wantResp, gotResp)
+ }
+ var gotCookie bool
+ for _, c := range w.Result().Cookies() {
+ if c.Name == sessionCookieName {
+ gotCookie = true
+ break
+ }
+ }
+ if gotCookie != tt.wantNewCookie {
+ t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie)
+ }
+ })
+ }
+}
+
+var (
+ testControlURL = "http://localhost:8080"
+ testAuthPath = "/a/12345"
+ testAuthPathSuccess = "/a/will-succeed"
+ testAuthPathError = "/a/will-error"
+)
+
// mockLocalAPI constructs a test localapi handler that can be used
// to simulate localapi responses without a functioning tailnet.
//
@@ -423,6 +572,43 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu
}
w.Header().Set("Content-Type", "application/json")
return
+ case "/localapi/v0/prefs":
+ prefs := ipn.Prefs{ControlURL: testControlURL}
+ if err := json.NewEncoder(w).Encode(prefs); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ return
+ case "/localapi/v0/debug-web-client": // used by TestServeTailscaleAuth
+ type reqData struct {
+ ID string
+ Src tailcfg.NodeID
+ }
+ var data reqData
+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+ http.Error(w, "invalid JSON body", http.StatusBadRequest)
+ return
+ }
+ if data.Src == 0 {
+ http.Error(w, "missing Src node", http.StatusBadRequest)
+ return
+ }
+ var resp *tailcfg.WebClientAuthResponse
+ if data.ID == "" {
+ resp = &tailcfg.WebClientAuthResponse{URL: testControlURL + testAuthPath}
+ } else if data.ID == testAuthPathSuccess {
+ resp = &tailcfg.WebClientAuthResponse{Complete: true}
+ } else if data.ID == testAuthPathError {
+ http.Error(w, "authenticated as wrong user", http.StatusUnauthorized)
+ return
+ }
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ return
default:
t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path)
}
diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go
index f9b040e8f..4151d94e7 100644
--- a/ipn/localapi/localapi.go
+++ b/ipn/localapi/localapi.go
@@ -2179,7 +2179,6 @@ func (h *Handler) serveDebugWebClient(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(resp.StatusCode)
}
func defBool(a string, def bool) bool {