diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 6815c5c8..507557bd 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -343,10 +343,17 @@ cross-site browser mutations. `GET`, `HEAD`, and `OPTIONS` are unaffected. Origin-less clients such as local CLI scripts remain compatible; a browser request explicitly marked `Sec-Fetch-Site: cross-site` is rejected even if it omits `Origin`. Tokenless requests also require a recognized `Host`: loopback -hosts are always accepted, and the configured bind host plus the exact -Tailscale hostname discovered at startup are added to the allowlist. This prevents DNS rebinding from turning a -same-origin attacker hostname into access to the local server. The explicitly -dangerous `--insecure` mode preserves its documented any-host behavior. +hosts are always accepted, and the configured bind host is added to the +allowlist. This prevents DNS rebinding from turning a same-origin attacker +hostname into access to the local server. The explicitly dangerous `--insecure` +mode preserves its documented any-host behavior. + +Tailscale Serve is only configured when `PI_WEB_TOKEN` is set. Serve proxies the +tailnet to the loopback server, so allowlisting its hostname for tokenless access +would silently widen a loopback-only deployment into unauthenticated tailnet-wide +access to the agent — defeating the same non-loopback token guard enforced at +startup. When no token is set, startup skips Serve and stays loopback-only; the +discovered Tailscale hostname is added to the allowlist only alongside a token. JSON handlers share `decodeJSONBody`, which caps request bodies at 2 MiB, rejects multiple JSON values, and rejects an explicit media type other than diff --git a/internal/app/app.go b/internal/app/app.go index 3916144f..39edaf7a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -127,7 +127,16 @@ func Main(version string) { url := fmt.Sprintf("http://%s", net.JoinHostPort(bindHost, *port)) var tailscaleURL string var tailscaleServe bool - if *hostOverride == "" { + if *hostOverride == "" && !authMiddleware.Enabled() { + // Tailscale Serve proxies the tailnet to this loopback server, and its + // hostname would be allowlisted for tokenless access below. Without a + // token that turns a loopback-only deployment into unauthenticated + // tailnet-wide access to the agent, so stay loopback-only instead. + fmt.Fprintf(os.Stderr, + "Tailscale Serve not configured: set %s to publish an HTTPS tailnet endpoint.\n"+ + " Without a token, pi-web stays loopback-only so tailnet peers cannot reach the agent unauthenticated.\n", + tokenEnvVar) + } else if *hostOverride == "" { tsCtx, tsCancel := context.WithTimeout(context.Background(), tailscaleConfigureTimeout) tsURL, tsOk, tsErr := configureTailscaleServe(tsCtx, *port) tsCancel() diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 957b5e50..c51e9371 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -132,6 +132,7 @@ func (a *Middleware) Wrap(h http.HandlerFunc) http.HandlerFunc { Value: got, Path: "/", HttpOnly: true, + Secure: isTLSRequest(r), SameSite: http.SameSiteLaxMode, MaxAge: 30 * 24 * 60 * 60, }) @@ -167,6 +168,19 @@ func (a *Middleware) allowsTokenlessHost(rawHost string) bool { return ok } +// isTLSRequest reports whether the request reached the client over HTTPS. The +// server itself always listens on loopback HTTP, so a direct connection is +// never TLS; Tailscale Serve terminates TLS and proxies with +// X-Forwarded-Proto: https, which is the only HTTPS path in practice. Marking +// the cookie Secure on that path keeps it off any cleartext request to the same +// host, while leaving plain loopback HTTP (no such header) unaffected. +func isTLSRequest(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") +} + func normalizeHostname(hostOrURL string) string { value := strings.TrimSpace(hostOrURL) if value == "" { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 08b9e75c..c3df5e60 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -74,6 +74,31 @@ func TestAuthAcceptsQueryAndRedirects(t *testing.T) { if !found.HttpOnly { t.Fatal("expected HttpOnly cookie") } + if found.Secure { + t.Fatal("expected cookie to not be Secure over plain HTTP") + } +} + +// Behind Tailscale Serve (X-Forwarded-Proto: https) the cookie must be Secure. +func TestAuthSetsSecureCookieForForwardedHTTPS(t *testing.T) { + a := New("secret") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/?token=secret", nil) + req.Header.Set("X-Forwarded-Proto", "https") + a.Wrap(okHandler)(rec, req) + var found *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == TokenCookieName { + found = c + break + } + } + if found == nil { + t.Fatalf("expected %s cookie to be set", TokenCookieName) + } + if !found.Secure { + t.Fatal("expected Secure cookie when forwarded proto is https") + } } // Query-based token with other params preserves them in redirect. diff --git a/internal/server/push.go b/internal/server/push.go index 9deaaabd..3ed1b886 100644 --- a/internal/server/push.go +++ b/internal/server/push.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -141,6 +142,15 @@ func (m *PushManager) handleVapid(w http.ResponseWriter, r *http.Request) { writeJSON(w, 0, map[string]any{"publicKey": m.PublicKey()}) } +// validPushEndpoint restricts subscriptions to absolute https:// URLs with a +// host. The endpoint is later POSTed to when notifying, so this keeps the push +// sender from being pointed at arbitrary schemes or internal hosts. Real Web +// Push services (FCM, Mozilla autopush, WNS) are always https. +func validPushEndpoint(endpoint string) bool { + u, err := url.Parse(endpoint) + return err == nil && u.Scheme == "https" && u.Host != "" +} + func (m *PushManager) handleSubscribe(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -150,7 +160,7 @@ func (m *PushManager) handleSubscribe(w http.ResponseWriter, r *http.Request) { if !decodeJSONBody(w, r, &sub) { return } - if sub.Endpoint == "" { + if !validPushEndpoint(sub.Endpoint) { writeJSONError(w, http.StatusBadRequest, "invalid subscription") return } diff --git a/internal/server/push_test.go b/internal/server/push_test.go index 0159a855..7a373792 100644 --- a/internal/server/push_test.go +++ b/internal/server/push_test.go @@ -42,6 +42,28 @@ func TestNewPushManager_PersistsVapidKeys(t *testing.T) { } } +func TestValidPushEndpoint(t *testing.T) { + cases := []struct { + endpoint string + want bool + }{ + {"https://fcm.googleapis.com/fcm/send/abc123", true}, + {"https://updates.push.services.mozilla.com/wpush/v2/xyz", true}, + {"", false}, + {"http://fcm.googleapis.com/fcm/send/abc", false}, // cleartext + {"http://127.0.0.1:31415/api/chat", false}, // SSRF to loopback + {"https:///fcm/send/abc", false}, // no host + {"file:///etc/passwd", false}, + {"ftp://example.com/x", false}, + {"not a url", false}, + } + for _, c := range cases { + if got := validPushEndpoint(c.endpoint); got != c.want { + t.Errorf("validPushEndpoint(%q) = %v, want %v", c.endpoint, got, c.want) + } + } +} + func TestNewPushManager_MigratesOldWebDir(t *testing.T) { tmp := t.TempDir() oldDir := filepath.Join(tmp, "web") diff --git a/user-docs/en/install.md b/user-docs/en/install.md index 60ec80ce..a4f251e5 100644 --- a/user-docs/en/install.md +++ b/user-docs/en/install.md @@ -201,7 +201,7 @@ pi-web --host 127.0.0.1 PI_WEB_TOKEN=$(openssl rand -hex 16) pi-web --host 192.168.1.50 ``` -By default, pi-web binds to `127.0.0.1`. If Tailscale is running with MagicDNS, pi-web also runs `tailscale serve --bg --https= http://127.0.0.1:` and prints the HTTPS tailnet URL. Any explicit non-loopback bind requires `PI_WEB_TOKEN` to be set; pass `--insecure` to override for local testing. +By default, pi-web binds to `127.0.0.1`. If Tailscale is running with MagicDNS **and `PI_WEB_TOKEN` is set**, pi-web also runs `tailscale serve --bg --https= http://127.0.0.1:` and prints the HTTPS tailnet URL. Without a token, pi-web stays loopback-only and skips Tailscale Serve, so tailnet peers cannot reach the agent unauthenticated. Any explicit non-loopback bind also requires `PI_WEB_TOKEN` to be set; pass `--insecure` to override for local testing. ## Remote Access @@ -216,11 +216,11 @@ sudo tailscale set --operator=$USER ``` ```bash -# 1. Start pi-web -pi-web +# 1. Start pi-web with a token so it publishes the Tailscale HTTPS endpoint +PI_WEB_TOKEN=$(openssl rand -hex 16) pi-web # 2. From any other Tailscale-connected device, open the printed -# "Tailscale HTTPS" URL. +# "Tailscale HTTPS" URL and enter the token once. ``` > By default, pi-web refuses to bind to a non-loopback address unless `PI_WEB_TOKEN` is set — anyone who can reach the bound address could otherwise view sessions and send instructions to pi. To override this guard for local-network testing, pass `--insecure`. **Don't use `--insecure` on Tailscale or any address reachable from outside your machine.**