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 <will@tailscale.com>
pull/9077/head
Will Norris 1 year ago committed by Will Norris
parent 0c3d343ea3
commit dc8287ab3b

@ -16,6 +16,8 @@ import (
"net/url" "net/url"
) )
const qnapPrefix = "/cgi-bin/qpkg/Tailscale/index.cgi/"
// authorizeQNAP authenticates the logged-in QNAP user and verifies // authorizeQNAP authenticates the logged-in QNAP user and verifies
// that they are authorized to use the web client. It returns true if the // that they are authorized to use the web client. It returns true if the
// request was handled and no further processing is required. // request was handled and no further processing is required.

@ -36,7 +36,7 @@ export default function useNodeData() {
const [isPosting, setIsPosting] = useState<boolean>(false) const [isPosting, setIsPosting] = useState<boolean>(false)
const fetchNodeData = useCallback(() => { const fetchNodeData = useCallback(() => {
apiFetch("/api/data") apiFetch("api/data")
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setData(d)) .then((d) => setData(d))
.catch((error) => console.error(error)) .catch((error) => console.error(error))

@ -15,6 +15,8 @@ import (
"tailscale.com/util/groupmember" "tailscale.com/util/groupmember"
) )
const synologyPrefix = "/webman/3rdparty/Tailscale/index.cgi/"
// authorizeSynology authenticates the logged-in Synology user and verifies // authorizeSynology authenticates the logged-in Synology user and verifies
// that they are authorized to use the web client. It returns true if the // that they are authorized to use the web client. It returns true if the
// request was handled and no further processing is required. // request was handled and no further processing is required.

@ -20,7 +20,7 @@ filteringLogger.info = (...args) => {
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: "/", base: "./",
plugins: [ plugins: [
paths(), paths(),
svgr(), svgr(),

@ -54,6 +54,7 @@ type Server struct {
devProxy *httputil.ReverseProxy // only filled when devMode is on devProxy *httputil.ReverseProxy // only filled when devMode is on
cgiMode bool cgiMode bool
cgiPath string
apiHandler http.Handler // csrf-protected api handler 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 indicates if the server is running as a CGI script.
CGIMode bool 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. // LocalClient is the tailscale.LocalClient to use for this web server.
// If nil, a new one will be created. // If nil, a new one will be created.
LocalClient *tailscale.LocalClient LocalClient *tailscale.LocalClient
@ -78,6 +82,7 @@ func NewServer(opts ServerOpts) (s *Server, cleanup func()) {
devMode: opts.DevMode, devMode: opts.DevMode,
lc: opts.LocalClient, lc: opts.LocalClient,
cgiMode: opts.CGIMode, cgiMode: opts.CGIMode,
cgiPath: opts.CGIPath,
} }
cleanup = func() {} cleanup = func() {}
if s.devMode { 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) { func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
@ -393,3 +416,17 @@ func (s *Server) csrfKey() []byte {
return key 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)
}
}

Loading…
Cancel
Save