Skip to content

Commit

Permalink
client/web: enforce full path for CGI platforms
Browse files Browse the repository at this point in the history
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>
Signed-off-by: Alex Paguis <alex@windscribe.com>
  • Loading branch information
willnorris authored and alexelisenko committed Feb 15, 2024
1 parent c3a80fe commit fd18a3f
Show file tree
Hide file tree
Showing 5 changed files with 44 additions and 3 deletions.
2 changes: 2 additions & 0 deletions client/web/qnap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion client/web/src/hooks/node-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default function useNodeData() {
const [isPosting, setIsPosting] = useState<boolean>(false)

const fetchNodeData = useCallback(() => {
apiFetch("/api/data")
apiFetch("api/data")
.then((r) => r.json())
.then((d) => setData(d))
.catch((error) => console.error(error))
Expand Down
2 changes: 2 additions & 0 deletions client/web/synology.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion client/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ filteringLogger.info = (...args) => {

// https://vitejs.dev/config/
export default defineConfig({
base: "/",
base: "./",
plugins: [
paths(),
svgr(),
Expand Down
39 changes: 38 additions & 1 deletion client/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}

0 comments on commit fd18a3f

Please sign in to comment.