mirror of
https://github.com/tailscale/tailscale.git
synced 2025-01-07 16:17:41 +00:00
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:
parent
c919ff540f
commit
da6eb076aa
@ -5,7 +5,7 @@ import useNodeData from "src/hooks/node-data"
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
// TODO(sonia): use isPosting value from useNodeData
|
// TODO(sonia): use isPosting value from useNodeData
|
||||||
// to fill loading states.
|
// to fill loading states.
|
||||||
const { data, updateNode } = useNodeData()
|
const { data, refreshData, updateNode } = useNodeData()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-14">
|
<div className="py-14">
|
||||||
@ -15,7 +15,11 @@ export default function App() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||||
<Header data={data} updateNode={updateNode} />
|
<Header
|
||||||
|
data={data}
|
||||||
|
refreshData={refreshData}
|
||||||
|
updateNode={updateNode}
|
||||||
|
/>
|
||||||
<IP data={data} />
|
<IP data={data} />
|
||||||
<State data={data} updateNode={updateNode} />
|
<State data={data} updateNode={updateNode} />
|
||||||
</main>
|
</main>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
import { apiFetch } from "src/api"
|
||||||
import { NodeData, NodeUpdate } from "src/hooks/node-data"
|
import { NodeData, NodeUpdate } from "src/hooks/node-data"
|
||||||
|
|
||||||
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
|
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
|
||||||
@ -9,9 +10,11 @@ import { NodeData, NodeUpdate } from "src/hooks/node-data"
|
|||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
data,
|
data,
|
||||||
|
refreshData,
|
||||||
updateNode,
|
updateNode,
|
||||||
}: {
|
}: {
|
||||||
data: NodeData
|
data: NodeData
|
||||||
|
refreshData: () => void
|
||||||
updateNode: (update: NodeUpdate) => void
|
updateNode: (update: NodeUpdate) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -89,7 +92,11 @@ export function Header({
|
|||||||
</button>{" "}
|
</button>{" "}
|
||||||
|{" "}
|
|{" "}
|
||||||
<button
|
<button
|
||||||
onClick={() => updateNode({ ForceLogout: true })}
|
onClick={() =>
|
||||||
|
apiFetch("/local/v0/logout", { method: "POST" })
|
||||||
|
.then(refreshData)
|
||||||
|
.catch((err) => alert("Logout failed: " + err.message))
|
||||||
|
}
|
||||||
className="hover:text-gray-700"
|
className="hover:text-gray-700"
|
||||||
>
|
>
|
||||||
Logout
|
Logout
|
||||||
|
@ -35,7 +35,7 @@ export default function useNodeData() {
|
|||||||
const [data, setData] = useState<NodeData>()
|
const [data, setData] = useState<NodeData>()
|
||||||
const [isPosting, setIsPosting] = useState<boolean>(false)
|
const [isPosting, setIsPosting] = useState<boolean>(false)
|
||||||
|
|
||||||
const fetchNodeData = useCallback(
|
const refreshData = useCallback(
|
||||||
() =>
|
() =>
|
||||||
apiFetch("/data")
|
apiFetch("/data")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@ -102,7 +102,7 @@ export default function useNodeData() {
|
|||||||
if (url) {
|
if (url) {
|
||||||
window.open(url, "_blank")
|
window.open(url, "_blank")
|
||||||
}
|
}
|
||||||
fetchNodeData()
|
refreshData()
|
||||||
})
|
})
|
||||||
.catch((err) => alert("Failed operation: " + err.message))
|
.catch((err) => alert("Failed operation: " + err.message))
|
||||||
},
|
},
|
||||||
@ -112,11 +112,11 @@ export default function useNodeData() {
|
|||||||
useEffect(
|
useEffect(
|
||||||
() => {
|
() => {
|
||||||
// Initial data load.
|
// Initial data load.
|
||||||
fetchNodeData()
|
refreshData()
|
||||||
|
|
||||||
// Refresh on browser tab focus.
|
// Refresh on browser tab focus.
|
||||||
const onVisibilityChange = () => {
|
const onVisibilityChange = () => {
|
||||||
document.visibilityState === "visible" && fetchNodeData()
|
document.visibilityState === "visible" && refreshData()
|
||||||
}
|
}
|
||||||
window.addEventListener("visibilitychange", onVisibilityChange)
|
window.addEventListener("visibilitychange", onVisibilityChange)
|
||||||
return () => {
|
return () => {
|
||||||
@ -128,5 +128,5 @@ export default function useNodeData() {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { data, updateNode, isPosting }
|
return { data, refreshData, updateNode, isPosting }
|
||||||
}
|
}
|
||||||
|
@ -18,11 +18,13 @@
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/gorilla/csrf"
|
"github.com/gorilla/csrf"
|
||||||
"tailscale.com/client/tailscale"
|
"tailscale.com/client/tailscale"
|
||||||
|
"tailscale.com/client/tailscale/apitype"
|
||||||
"tailscale.com/envknob"
|
"tailscale.com/envknob"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnstate"
|
"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) {
|
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||||
switch path {
|
switch {
|
||||||
case "/data":
|
case path == "/data":
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case httpm.GET:
|
case httpm.GET:
|
||||||
s.serveGetNodeDataJSON(w, r)
|
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)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
case strings.HasPrefix(path, "/local/"):
|
||||||
|
s.proxyRequestToLocalAPI(w, r)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
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.
|
// 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 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.
|
// If the server is running in CGI mode, the key is cached to disk and reused between requests.
|
||||||
|
@ -4,8 +4,16 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"tailscale.com/client/tailscale"
|
||||||
|
"tailscale.com/net/memnet"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestQnapAuthnURL(t *testing.T) {
|
func TestQnapAuthnURL(t *testing.T) {
|
||||||
@ -62,3 +70,66 @@ func TestQnapAuthnURL(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestServeAPI tests the web client api's handling of
|
||||||
|
// 1. invalid endpoint errors
|
||||||
|
// 2. localapi proxy allowlist
|
||||||
|
func TestServeAPI(t *testing.T) {
|
||||||
|
lal := memnet.Listen("local-tailscaled.sock:80")
|
||||||
|
defer lal.Close()
|
||||||
|
|
||||||
|
// Serve dummy localapi. Just returns "success".
|
||||||
|
go func() {
|
||||||
|
localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintf(w, "success")
|
||||||
|
})}
|
||||||
|
defer localapi.Close()
|
||||||
|
if err := localapi.Serve(lal); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
s := &Server{lc: &tailscale.LocalClient{Dial: lal.Dial}}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reqPath string
|
||||||
|
wantResp string
|
||||||
|
wantStatus int
|
||||||
|
}{{
|
||||||
|
name: "invalid_endpoint",
|
||||||
|
reqPath: "/not-an-endpoint",
|
||||||
|
wantResp: "invalid endpoint",
|
||||||
|
wantStatus: http.StatusNotFound,
|
||||||
|
}, {
|
||||||
|
name: "not_in_localapi_allowlist",
|
||||||
|
reqPath: "/local/v0/not-allowlisted",
|
||||||
|
wantResp: "/v0/not-allowlisted not allowed from localapi proxy",
|
||||||
|
wantStatus: http.StatusForbidden,
|
||||||
|
}, {
|
||||||
|
name: "in_localapi_allowlist",
|
||||||
|
reqPath: "/local/v0/logout",
|
||||||
|
wantResp: "success", // Successfully allowed to hit localapi.
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
}}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("POST", "/api"+tt.reqPath, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
s.serveAPI(w, r)
|
||||||
|
res := w.Result()
|
||||||
|
defer res.Body.Close()
|
||||||
|
if gotStatus := res.StatusCode; tt.wantStatus != gotStatus {
|
||||||
|
t.Errorf("wrong status; want=%q, got=%q", tt.wantStatus, gotStatus)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gotResp := strings.TrimSuffix(string(body), "\n") // trim trailing newline
|
||||||
|
if tt.wantResp != gotResp {
|
||||||
|
t.Errorf("wrong response; want=%q, got=%q", tt.wantResp, gotResp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user