mirror of
https://github.com/tailscale/tailscale.git
synced 2025-08-11 21:27:31 +00:00
client/web: add csrf protection to web client api
Adds csrf protection and hooks up an initial POST request from the React web client. Updates tailscale/corp#13775 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:

committed by
Sonia Appasamy

parent
77ff705545
commit
077bbb8403
41
client/web/api.go
Normal file
41
client/web/api.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"tailscale.com/util/httpm"
|
||||
)
|
||||
|
||||
type api struct {
|
||||
s *Server
|
||||
}
|
||||
|
||||
// ServeHTTP 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 (a *api) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||
user, err := authorize(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api")
|
||||
switch path {
|
||||
case "/data":
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
a.s.serveGetNodeDataJSON(w, r, user)
|
||||
case httpm.POST:
|
||||
a.s.servePostNodeUpdate(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||
}
|
@@ -8,10 +8,12 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"classnames": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
|
32
client/web/src/api.ts
Normal file
32
client/web/src/api.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
let csrfToken: string
|
||||
|
||||
// apiFetch wraps the standard JS fetch function
|
||||
// with csrf header management.
|
||||
export function apiFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit | undefined
|
||||
): Promise<Response> {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: withCsrfToken(init?.headers),
|
||||
}).then((r) => {
|
||||
updateCsrfToken(r)
|
||||
if (!r.ok) {
|
||||
return r.text().then((err) => {
|
||||
throw new Error(err)
|
||||
})
|
||||
}
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
function withCsrfToken(h?: HeadersInit): HeadersInit {
|
||||
return { ...h, "X-CSRF-Token": csrfToken }
|
||||
}
|
||||
|
||||
function updateCsrfToken(r: Response) {
|
||||
const tok = r.headers.get("X-CSRF-Token")
|
||||
if (tok) {
|
||||
csrfToken = tok
|
||||
}
|
||||
}
|
@@ -3,7 +3,9 @@ import { Footer, Header, IP, State } from "src/components/legacy"
|
||||
import useNodeData from "src/hooks/node-data"
|
||||
|
||||
export default function App() {
|
||||
const data = useNodeData()
|
||||
// TODO(sonia): use isPosting value from useNodeData
|
||||
// to fill loading states.
|
||||
const { data, updateNode } = useNodeData()
|
||||
|
||||
return (
|
||||
<div className="py-14">
|
||||
@@ -15,7 +17,7 @@ export default function App() {
|
||||
<main className="container max-w-lg mx-auto mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||
<Header data={data} />
|
||||
<IP data={data} />
|
||||
<State data={data} />
|
||||
<State data={data} updateNode={updateNode} />
|
||||
</main>
|
||||
<Footer data={data} />
|
||||
</>
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import cx from "classnames"
|
||||
import React from "react"
|
||||
import { NodeData } from "src/hooks/node-data"
|
||||
import { NodeData, NodeUpdate } from "src/hooks/node-data"
|
||||
|
||||
// TODO(tailscale/corp#13775): legacy.tsx contains a set of components
|
||||
// that (crudely) implement the pre-2023 web client. These are implemented
|
||||
@@ -162,9 +163,13 @@ export function IP(props: { data: NodeData }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function State(props: { data: NodeData }) {
|
||||
const { data } = props
|
||||
|
||||
export function State({
|
||||
data,
|
||||
updateNode,
|
||||
}: {
|
||||
data: NodeData
|
||||
updateNode: (update: NodeUpdate) => void
|
||||
}) {
|
||||
switch (data.Status) {
|
||||
case "NeedsLogin":
|
||||
case "NoState":
|
||||
@@ -232,25 +237,20 @@ export function State(props: { data: NodeData }) {
|
||||
device name or IP address above.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<a href="#" className="mb-4 js-advertiseExitNode">
|
||||
{data.AdvertiseExitNode ? (
|
||||
<button
|
||||
className="button button-red button-medium"
|
||||
id="enabled"
|
||||
>
|
||||
Stop advertising Exit Node
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="button button-blue button-medium"
|
||||
id="enabled"
|
||||
>
|
||||
Advertise as Exit Node
|
||||
</button>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
className={cx("button button-medium mb-4", {
|
||||
"button-red": data.AdvertiseExitNode,
|
||||
"button-blue": !data.AdvertiseExitNode,
|
||||
})}
|
||||
id="enabled"
|
||||
onClick={() =>
|
||||
updateNode({ AdvertiseExitNode: !data.AdvertiseExitNode })
|
||||
}
|
||||
>
|
||||
{data.AdvertiseExitNode
|
||||
? "Stop advertising Exit Node"
|
||||
: "Advertise as Exit Node"}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { apiFetch } from "src/api"
|
||||
|
||||
export type NodeData = {
|
||||
Profile: UserProfile
|
||||
@@ -22,16 +23,104 @@ export type UserProfile = {
|
||||
ProfilePicURL: string
|
||||
}
|
||||
|
||||
export type NodeUpdate = {
|
||||
AdvertiseRoutes?: string
|
||||
AdvertiseExitNode?: boolean
|
||||
Reauthenticate?: boolean
|
||||
ForceLogout?: boolean
|
||||
}
|
||||
|
||||
// useNodeData returns basic data about the current node.
|
||||
export default function useNodeData() {
|
||||
const [data, setData] = useState<NodeData>()
|
||||
const [isPosting, setIsPosting] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/data")
|
||||
.then((response) => response.json())
|
||||
.then((json) => setData(json))
|
||||
const fetchNodeData = useCallback(() => {
|
||||
apiFetch("/api/data")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setData(data))
|
||||
.catch((error) => console.error(error))
|
||||
}, [])
|
||||
}, [setData])
|
||||
|
||||
return data
|
||||
const updateNode = useCallback(
|
||||
(update: NodeUpdate) => {
|
||||
// The contents of this function are mostly copied over
|
||||
// from the legacy client's web.html file.
|
||||
// It makes all data updates through one API endpoint.
|
||||
// As we build out the web client in React,
|
||||
// this endpoint will eventually be deprecated.
|
||||
|
||||
if (isPosting || !data) {
|
||||
return
|
||||
}
|
||||
setIsPosting(true)
|
||||
|
||||
update = {
|
||||
// Default to current data value for any unset fields.
|
||||
AdvertiseRoutes:
|
||||
update.AdvertiseRoutes !== undefined
|
||||
? update.AdvertiseRoutes
|
||||
: data.AdvertiseRoutes,
|
||||
AdvertiseExitNode:
|
||||
update.AdvertiseExitNode !== undefined
|
||||
? update.AdvertiseExitNode
|
||||
: data.AdvertiseExitNode,
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const nextParams = new URLSearchParams({ up: "true" })
|
||||
const token = urlParams.get("SynoToken")
|
||||
if (token) {
|
||||
nextParams.set("SynoToken", token)
|
||||
}
|
||||
const search = nextParams.toString()
|
||||
const url = `/api/data${search ? `?${search}` : ""}`
|
||||
|
||||
var body, contentType: string
|
||||
|
||||
if (data.IsUnraid) {
|
||||
const params = new URLSearchParams()
|
||||
params.append("csrf_token", data.UnraidToken)
|
||||
params.append("ts_data", JSON.stringify(update))
|
||||
body = params.toString()
|
||||
contentType = "application/x-www-form-urlencoded;charset=UTF-8"
|
||||
} else {
|
||||
body = JSON.stringify(update)
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
apiFetch(url, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": contentType },
|
||||
body: body,
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
setIsPosting(false)
|
||||
const err = r["error"]
|
||||
if (err) {
|
||||
throw new Error(err)
|
||||
}
|
||||
const url = r["url"]
|
||||
if (url) {
|
||||
if (data.IsUnraid) {
|
||||
window.open(url, "_blank")
|
||||
} else {
|
||||
document.location.href = url
|
||||
}
|
||||
}
|
||||
fetchNodeData()
|
||||
})
|
||||
.catch((err) => alert("Failed operation: " + err.message))
|
||||
},
|
||||
[data]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
fetchNodeData,
|
||||
// Initial data load.
|
||||
[]
|
||||
)
|
||||
|
||||
return { data, updateNode, isPosting }
|
||||
}
|
||||
|
@@ -7,6 +7,7 @@ package web
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"tailscale.com/client/tailscale"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/ipn"
|
||||
@@ -31,7 +33,6 @@ import (
|
||||
"tailscale.com/net/netutil"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/util/groupmember"
|
||||
"tailscale.com/util/httpm"
|
||||
"tailscale.com/version/distro"
|
||||
)
|
||||
|
||||
@@ -52,6 +53,8 @@ type Server struct {
|
||||
|
||||
devMode bool
|
||||
devProxy *httputil.ReverseProxy // only filled when devMode is on
|
||||
|
||||
apiHandler http.Handler // csrf-protected api handler
|
||||
}
|
||||
|
||||
// NewServer constructs a new Tailscale web client server.
|
||||
@@ -70,6 +73,11 @@ func NewServer(devMode bool, lc *tailscale.LocalClient) (s *Server, cleanup func
|
||||
if s.devMode {
|
||||
cleanup = s.startDevServer()
|
||||
s.addProxyToDevServer()
|
||||
|
||||
// Create new handler for "/api" requests.
|
||||
// And protect with gorilla csrf.
|
||||
csrfProtect := csrf.Protect(csrfKey())
|
||||
s.apiHandler = csrfProtect(&api{s: s})
|
||||
}
|
||||
return s, cleanup
|
||||
}
|
||||
@@ -271,19 +279,9 @@ req.send(null);
|
||||
// ServeHTTP processes all requests for the Tailscale web client.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if s.devMode {
|
||||
if r.URL.Path == "/api/data" {
|
||||
user, err := authorize(w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case httpm.GET:
|
||||
s.serveGetNodeDataJSON(w, r, user)
|
||||
case httpm.POST:
|
||||
s.servePostNodeUpdate(w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
// Pass through to other handlers via CSRF protection.
|
||||
s.apiHandler.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// When in dev mode, proxy to the Vite dev server.
|
||||
@@ -527,3 +525,14 @@ func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// csrfKey creates a new random csrf token.
|
||||
// If an error surfaces during key creation,
|
||||
// the error is logged and the active process terminated.
|
||||
func csrfKey() []byte {
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
log.Fatal("error generating CSRF key: %w", err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
@@ -543,6 +543,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.5.tgz#ae69bcbb1bebb68c4ac0b11e9d8ed04526b3562b"
|
||||
integrity sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==
|
||||
|
||||
"@types/classnames@^2.2.10":
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.3.1.tgz#3c2467aa0f1a93f1f021e3b9bcf938bd5dfdc0dd"
|
||||
integrity sha512-zeOWb0JGBoVmlQoznvqXbE0tEC/HONsnoUNH19Hc96NFsTAwTXbTqb8FMYkru1F/iqp7a18Ws3nWJvtA1sHD1A==
|
||||
dependencies:
|
||||
classnames "*"
|
||||
|
||||
"@types/estree@^1.0.0":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.1.tgz#aa22750962f3bf0e79d753d3cc067f010c95f194"
|
||||
@@ -798,6 +805,11 @@ chokidar@^3.5.3:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
classnames@*, classnames@^2.3.1:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
|
||||
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
|
||||
|
||||
color-convert@^1.9.0:
|
||||
version "1.9.3"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
|
||||
|
Reference in New Issue
Block a user