Browse Source

client/web: keep redirects on-site (#10525)

Ensure we don't create Location: header URLs that have leading //, which is a
schema-less reference to arbitrary 3rd-party sites. That is, //example.com/foo
redirects off-site, while /example.com/foo is an on-site path URL.

Fixes tailscale/corp#16268

Signed-off-by: Chris Palmer <[email protected]>
Chris Palmer 2 years ago
parent
commit
b62a3fc895
2 changed files with 52 additions and 1 deletions
  1. 8 0
      client/web/web.go
  2. 44 1
      client/web/web_test.go

+ 8 - 0
client/web/web.go

@@ -15,6 +15,7 @@ import (
 	"net/http"
 	"net/netip"
 	"os"
+	"path"
 	"path/filepath"
 	"slices"
 	"strings"
@@ -174,6 +175,13 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
 		newAuthURL:  opts.NewAuthURL,
 		waitAuthURL: opts.WaitAuthURL,
 	}
+	if opts.PathPrefix != "" {
+		// In enforcePrefix, we add the necessary leading '/'. If we did not
+		// strip 1 or more leading '/'s here, we would end up redirecting
+		// clients to e.g. //example.com (a schema-less URL that points to
+		// another site). See https://github.com/tailscale/corp/issues/16268.
+		s.pathPrefix = strings.TrimLeft(path.Clean(opts.PathPrefix), "/\\")
+	}
 	if s.mode == ManageServerMode {
 		if opts.NewAuthURL == nil {
 			return nil, fmt.Errorf("must provide a NewAuthURL implementation")

+ 44 - 1
client/web/web_test.go

@@ -939,6 +939,49 @@ func TestServeAPIAuthMetricLogging(t *testing.T) {
 	}
 }
 
+func TestNoOffSiteRedirect(t *testing.T) {
+	options := ServerOpts{
+		Mode: LoginServerMode,
+		// Emulate the admin using a --prefix option with leading slashes:
+		PathPrefix: "//evil.example.com/goat",
+		CGIMode:    true,
+	}
+	s, err := NewServer(options)
+	if err != nil {
+		t.Error(err)
+	}
+
+	tests := []struct {
+		name         string
+		target       string
+		wantHandled  bool
+		wantLocation string
+	}{
+		{
+			name:   "2-slashes",
+			target: "http://localhost//evil.example.com/goat",
+			// We must also get the trailing slash added:
+			wantLocation: "/evil.example.com/goat/",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			s.logf = t.Logf
+			r := httptest.NewRequest(httpm.GET, tt.target, nil)
+			w := httptest.NewRecorder()
+			s.ServeHTTP(w, r)
+			res := w.Result()
+			defer res.Body.Close()
+
+			location := w.Header().Get("Location")
+			if location != tt.wantLocation {
+				t.Errorf("request(%q) got wrong location; want=%q, got=%q", tt.target, tt.wantLocation, location)
+			}
+		})
+	}
+}
+
 func TestRequireTailscaleIP(t *testing.T) {
 	self := &ipnstate.PeerStatus{
 		TailscaleIPs: []netip.Addr{
@@ -1007,7 +1050,7 @@ func TestRequireTailscaleIP(t *testing.T) {
 	}
 
 	for _, tt := range tests {
-		t.Run(tt.target, func(t *testing.T) {
+		t.Run(tt.name, func(t *testing.T) {
 			s.logf = t.Logf
 			r := httptest.NewRequest(httpm.GET, tt.target, nil)
 			w := httptest.NewRecorder()