Skip to content
Merged
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
15 changes: 11 additions & 4 deletions docs/architecture/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
14 changes: 14 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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 == "" {
Expand Down
25 changes: 25 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion internal/server/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
22 changes: 22 additions & 0 deletions internal/server/push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 4 additions & 4 deletions user-docs/en/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<port> http://127.0.0.1:<port>` 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=<port> http://127.0.0.1:<port>` 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

Expand All @@ -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.**
Expand Down