Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ type TailnetSrv struct {
ServePlaintext bool
Timeout time.Duration
AllowedPrefixes prefixes
DeniedPrefixes prefixes
StripPrefix bool
StateDir string
AuthkeyPath string
Expand Down Expand Up @@ -194,6 +195,7 @@ func TailnetSrvFromArgs(args []string) (*ValidTailnetSrv, *ffcli.Command, error)
fs.BoolVar(&s.ServePlaintext, "plaintext", false, "Serve plaintext HTTP without TLS")
fs.DurationVar(&s.Timeout, "timeout", 1*time.Minute, "Timeout connecting to the tailnet")
fs.Var(&s.AllowedPrefixes, "prefix", "Allowed URL prefixes; if none is set, all prefixes are allowed")
fs.Var(&s.DeniedPrefixes, "denyPrefix", "Denied URL prefixes; if none is set, no prefixes are denied")
fs.BoolVar(&s.StripPrefix, "stripPrefix", true, "Strip prefixes that matched; best set to false if allowing multiple prefixes")
fs.StringVar(&s.StateDir, "stateDir", os.Getenv("TS_STATE_DIR"), "Directory containing the persistent tailscale status files. Can also be set by $TS_STATE_DIR; this option takes precedence.")
fs.StringVar(&s.AuthkeyPath, "authkeyPath", "", "File containing a tailscale auth key. Key is assumed to be in $TS_AUTHKEY in absence of this option.")
Expand Down Expand Up @@ -343,8 +345,13 @@ func (s *ValidTailnetSrv) Run(ctx context.Context) error {
}
s.client, err = srv.LocalClient()
if err != nil {
if slices.ContainsFunc(s.AllowedPrefixes, func(p prefix) bool { return p.matchIf != matchEither }) {
return fmt.Errorf("-prefix rules with a provenance (tailnet: or funnel:) require that a local tailscale client is available: %w", err)
prefixHasProvenance := func(p prefix) bool { return p.matchIf != matchEither }
const errMsg = "%s rules with a provenance (tailnet: or funnel:) require that a local tailscale client is available %w"
if slices.ContainsFunc(s.AllowedPrefixes, prefixHasProvenance) {
return fmt.Errorf(errMsg, "-prefix", err)
}
if slices.ContainsFunc(s.DeniedPrefixes, prefixHasProvenance) {
return fmt.Errorf(errMsg, "-denyPrefix", err)
}
slog.Warn("could not get a local tailscale client. Whois headers will not work.",
"error", err,
Expand Down Expand Up @@ -397,6 +404,7 @@ func (s *ValidTailnetSrv) Run(ctx context.Context) error {
"listenAddr", s.ListenAddr,
"tags", s.Tags,
"prefixes", s.AllowedPrefixes,
"deniedPrefixes", s.DeniedPrefixes,
"destURL", s.DestURL,
"plaintext", s.ServePlaintext,
"funnel", s.Funnel,
Expand Down
112 changes: 112 additions & 0 deletions cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,118 @@ func TestPrefixServing(t *testing.T) {
}
}

func TestPrefixDenied(t *testing.T) {
testmux := http.NewServeMux()
testmux.HandleFunc("/subpath", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("wrong"))
})
testmux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
ts := httptest.NewServer(testmux)
t.Cleanup(ts.Close)
s, _, err := TailnetSrvFromArgs([]string{"tsnsrv", "-name", "TestPrefixDenied", "-ephemeral",
"-denyPrefix", "/subpath",
"-denyPrefix", "/other/subpath",
"-denyPrefix", "funnel:/funnel-only-subpath",
"-denyPrefix", "tailnet:/tsnet-only-subpath",
ts.URL,
})
require.NoError(t, err)

// The routes denied on the tsnet must fail for requests on the tailnet:
mux := s.mux(http.DefaultTransport, false)
proxy := httptest.NewServer(mux)
pc := proxy.Client()
respOk, err := pc.Get(proxy.URL)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, respOk.StatusCode)
body, err := io.ReadAll(respOk.Body)
require.NoError(t, err)
assert.Equal(t, "ok", string(body))
for _, tp := range []string{
"/subpath",
"/other/subpath",
"/tsnet-only-subpath",
} {
t.Run(fmt.Sprintf("tailnet:%s", tp), func(t *testing.T) {
subpath := tp
t.Parallel()
// Subpath itself:
resp404, err := pc.Get(proxy.URL + subpath)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp404.StatusCode)
// Subpaths of subpath:
resp404, err = pc.Get(proxy.URL + subpath + "/hi")
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp404.StatusCode)
})
}

// The routes denied on the funnel must fail for requests from the funnel:
mux = s.mux(http.DefaultTransport, true)
funnelProxy := httptest.NewServer(mux)
fpc := funnelProxy.Client()
respOk, err = fpc.Get(funnelProxy.URL)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, respOk.StatusCode)
for _, tp := range []string{
"/other/subpath",
"/funnel-only-subpath",
} {
t.Run(fmt.Sprintf("funnel:%s", tp), func(t *testing.T) {
subpath := tp
t.Parallel()
// Subpath itself:
resp404, err := fpc.Get(funnelProxy.URL + subpath)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp404.StatusCode)
// Subpaths of subpath:
resp404, err = fpc.Get(funnelProxy.URL + subpath + "/hi")
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp404.StatusCode)
})
}

// funnel-only routes are denied on the tailnet:
for _, tp := range []struct {
path string
funnel bool
}{
{"/funnel-only-subpath", true},
{"/tsnet-only-subpath", false},
} {
t.Run(fmt.Sprintf("positive:%s funnel:%v", tp.path, tp.funnel), func(t *testing.T) {
subpath := tp
t.Parallel()
var resp *http.Response
if subpath.funnel {
resp, err = fpc.Get(funnelProxy.URL + subpath.path)
} else {
resp, err = pc.Get(proxy.URL + subpath.path)
}
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, "404 page not found\n", string(body))
// Subpaths of subpath:
if subpath.funnel {
resp, err = fpc.Get(funnelProxy.URL + subpath.path + "/hi")
} else {
resp, err = pc.Get(proxy.URL + subpath.path + "/hi")
}
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
assert.Equal(t, "404 page not found\n", string(body))
})
}
}

func TestRouting(t *testing.T) {
for _, elt := range []struct {
name, fromPath, toURLPath, requestPath, expectedPath string
Expand Down
34 changes: 27 additions & 7 deletions proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,15 +185,35 @@ func (s *ValidTailnetSrv) setWhoisHeaders(r *httputil.ProxyRequest) *apitype.Who
}

// matchPrefixes acts like the http.StripPrefix middleware, except
// that it checks against several allowed prefixes (an empty list
// means that all prefixes are allowed); if no prefixes match, it
// that it checks against several allowed prefixes and denied prefixes
// (an empty allowed list means that all prefixes are allowed and an empty
// denied list means that no prefixes are denied); if no prefixes match, it
// returns 404.
func matchPrefixes(prefixes []prefix, strip bool, forFunnel bool, handler http.Handler) http.Handler {
if len(prefixes) == 0 {
func matchPrefixes(allowedPrefixes, deniedPrefixes []prefix, strip bool, forFunnel bool, handler http.Handler) http.Handler {
if len(allowedPrefixes) == 0 && len(deniedPrefixes) == 0 {
return handler
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, prefix := range prefixes {
for _, prefix := range deniedPrefixes {
if ok, _ := prefix.matches(r.URL, forFunnel); ok {
slog.InfoCtx(r.Context(), "URL prefix denied",
"url", r.URL,
"prefixes", deniedPrefixes,
"forFunnel", forFunnel,
)
http.NotFound(w, r)
return
}
}

// Execute handler and return if we haven't denied this request and we have no
// allowed prefixes to look at.
if len(allowedPrefixes) == 0 {
handler.ServeHTTP(w, r)
return
}

for _, prefix := range allowedPrefixes {
if ok, stripData := prefix.matches(r.URL, forFunnel); ok {
r2 := new(http.Request)
*r2 = *r
Expand All @@ -209,7 +229,7 @@ func matchPrefixes(prefixes []prefix, strip bool, forFunnel bool, handler http.H
}
slog.WarnCtx(r.Context(), "URL prefix not allowed",
"url", r.URL,
"prefixes", prefixes,
"prefixes", allowedPrefixes,
"forFunnel", forFunnel,
)
http.NotFound(w, r)
Expand All @@ -225,7 +245,7 @@ func (s *ValidTailnetSrv) mux(transport http.RoundTripper, forFunnel bool) http.
}
mux := http.NewServeMux()

mux.Handle("/", matchPrefixes(s.AllowedPrefixes, s.StripPrefix, forFunnel, proxy))
mux.Handle("/", matchPrefixes(s.AllowedPrefixes, s.DeniedPrefixes, s.StripPrefix, forFunnel, proxy))

return mux
}