From dc8287ab3b88d30dc19550fe0b7fb5c36e920387 Mon Sep 17 00:00:00 2001 From: Will Norris Date: Thu, 24 Aug 2023 12:56:09 -0700 Subject: [PATCH] client/web: enforce full path for CGI platforms Synology and QNAP both run the web client as a CGI script. The old web client didn't care too much about requests paths, since there was only a single GET and POST handler. The new client serves assets on different paths, so now we need to care. First, enforce that the CGI script is always accessed from its full path, including a trailing slash (e.g. /cgi-bin/tailscale/index.cgi/). Then, strip that prefix off before passing the request along to the main serve handler. This allows for properly serving both static files and the API handler in a CGI environment. Also add a CGIPath option to allow other CGI environments to specify a custom path. Finally, update vite and one "api/data" call to no longer assume that we are always serving at the root path of "/". Updates tailscale/corp#13775 Signed-off-by: Will Norris --- client/web/qnap.go | 2 ++ client/web/src/hooks/node-data.ts | 2 +- client/web/synology.go | 2 ++ client/web/vite.config.ts | 2 +- client/web/web.go | 39 ++++++++++++++++++++++++++++++- 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/client/web/qnap.go b/client/web/qnap.go index d3b1d8dd7..b3c0879c1 100644 --- a/client/web/qnap.go +++ b/client/web/qnap.go @@ -16,6 +16,8 @@ import ( "net/url" ) +const qnapPrefix = "/cgi-bin/qpkg/Tailscale/index.cgi/" + // authorizeQNAP authenticates the logged-in QNAP user and verifies // that they are authorized to use the web client. It returns true if the // request was handled and no further processing is required. diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index 2369b4d57..21470c766 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -36,7 +36,7 @@ export default function useNodeData() { const [isPosting, setIsPosting] = useState(false) const fetchNodeData = useCallback(() => { - apiFetch("/api/data") + apiFetch("api/data") .then((r) => r.json()) .then((d) => setData(d)) .catch((error) => console.error(error)) diff --git a/client/web/synology.go b/client/web/synology.go index 7c3f82c11..70a024ace 100644 --- a/client/web/synology.go +++ b/client/web/synology.go @@ -15,6 +15,8 @@ import ( "tailscale.com/util/groupmember" ) +const synologyPrefix = "/webman/3rdparty/Tailscale/index.cgi/" + // authorizeSynology authenticates the logged-in Synology user and verifies // that they are authorized to use the web client. It returns true if the // request was handled and no further processing is required. diff --git a/client/web/vite.config.ts b/client/web/vite.config.ts index b36a5c287..6677195b1 100644 --- a/client/web/vite.config.ts +++ b/client/web/vite.config.ts @@ -20,7 +20,7 @@ filteringLogger.info = (...args) => { // https://vitejs.dev/config/ export default defineConfig({ - base: "/", + base: "./", plugins: [ paths(), svgr(), diff --git a/client/web/web.go b/client/web/web.go index a6e3ef589..2a2313a67 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -54,6 +54,7 @@ type Server struct { devProxy *httputil.ReverseProxy // only filled when devMode is on cgiMode bool + cgiPath string apiHandler http.Handler // csrf-protected api handler } @@ -64,6 +65,9 @@ type ServerOpts struct { // CGIMode indicates if the server is running as a CGI script. CGIMode bool + // If running in CGIMode, CGIPath is the URL path prefix to the CGI script. + CGIPath string + // LocalClient is the tailscale.LocalClient to use for this web server. // If nil, a new one will be created. LocalClient *tailscale.LocalClient @@ -78,6 +82,7 @@ func NewServer(opts ServerOpts) (s *Server, cleanup func()) { devMode: opts.DevMode, lc: opts.LocalClient, cgiMode: opts.CGIMode, + cgiPath: opts.CGIPath, } cleanup = func() {} if s.devMode { @@ -115,7 +120,25 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - s.serve(w, r) + handler := s.serve + + // if running in cgi mode, strip the cgi path prefix + if s.cgiMode { + prefix := s.cgiPath + if prefix == "" { + switch distro.Get() { + case distro.Synology: + prefix = synologyPrefix + case distro.QNAP: + prefix = qnapPrefix + } + } + if prefix != "" { + handler = enforcePrefix(prefix, handler) + } + } + + handler(w, r) } func (s *Server) serve(w http.ResponseWriter, r *http.Request) { @@ -393,3 +416,17 @@ func (s *Server) csrfKey() []byte { return key } + +// enforcePrefix returns a HandlerFunc that enforces a given path prefix is used in requests, +// then strips it before invoking h. +// Unlike http.StripPrefix, it does not return a 404 if the prefix is not present. +// Instead, it returns a redirect to the prefix path. +func enforcePrefix(prefix string, h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, prefix) { + http.Redirect(w, r, prefix, http.StatusFound) + return + } + http.StripPrefix(prefix, h).ServeHTTP(w, r) + } +}