From d5a6a1afd9d227d576d4e4a08d07ee5f3b19ef72 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 18 Aug 2026 10:12:51 +0200 Subject: [PATCH] fix(api): mint owner pairing codes only at the box A LAN peer could print an owner QR and keep control from anywhere. Viewer invites still work from the LAN. Loopback, or a house-password proof, can still mint an owner. --- .changeset/owner-pairing-at-the-box.md | 5 +++ docs/operations.md | 3 ++ go/internal/api/api_app_link.go | 22 ++++++++++++ go/internal/api/api_app_link_sharing_test.go | 13 ++++--- go/internal/api/api_app_link_test.go | 38 ++++++++++++++++++-- 5 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 .changeset/owner-pairing-at-the-box.md diff --git a/.changeset/owner-pairing-at-the-box.md b/.changeset/owner-pairing-at-the-box.md new file mode 100644 index 000000000..eacf8e841 --- /dev/null +++ b/.changeset/owner-pairing-at-the-box.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +An owner pairing code can only be minted on the box itself, or after the house password is on. A viewer invite still works from the LAN. diff --git a/docs/operations.md b/docs/operations.md index 23d6f2578..c4e555f4e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -143,6 +143,9 @@ sets a session cookie (`ftw_lan`, 12 hours). Loopback (`127.0.0.1` / `::1`) never asks. Live status stays readable without the password; a viewer caller is minted for those reads. +An owner pairing QR is minted only from loopback or with the house +password. A viewer invite still works from the LAN. + The FTW app and Home Assistant MQTT are unchanged. Recovery: `curl` to `127.0.0.1`, or set `api.lan_auth: false` in diff --git a/go/internal/api/api_app_link.go b/go/internal/api/api_app_link.go index a3398d472..5fcf764da 100644 --- a/go/internal/api/api_app_link.go +++ b/go/internal/api/api_app_link.go @@ -162,6 +162,9 @@ func (s *Server) handleAppLinkPairing(w http.ResponseWriter, r *http.Request) { if !s.appLinkRoleAllowed(w, r, req.Role) { return } + if !s.appLinkOwnerMintAllowed(w, r, req.Role) { + return + } // A spoken code is minted at the box and nowhere else. // @@ -448,6 +451,25 @@ func (s *Server) appLinkRoleAllowed(w http.ResponseWriter, r *http.Request, aske return true } +// appLinkOwnerMintAllowed refuses an owner code on the open LAN. +// +// Presence on a private address is not enough. That is how a guest or a +// ZeroTier peer turns "I can reach :8080" into a Noise owner that works +// from anywhere. Loopback is the box itself. A house-password proof is +// the other door, and it only exists when api.lan_auth is on. +func (s *Server) appLinkOwnerMintAllowed(w http.ResponseWriter, r *http.Request, asked string) bool { + if asked != apiauth.RoleOwner || appLinkOverSession(r) || isLoopbackClient(r.RemoteAddr) { + return true + } + houseOK, checked := lanSecretFrom(r.Context()) + if checked && houseOK { + return true + } + writeAppLinkError(w, http.StatusForbidden, + "making another owner is done on the box, or after the house password is on.") + return false +} + func writeAppLinkError(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg}) } diff --git a/go/internal/api/api_app_link_sharing_test.go b/go/internal/api/api_app_link_sharing_test.go index 5ac8c8fc6..61f5bf2f9 100644 --- a/go/internal/api/api_app_link_sharing_test.go +++ b/go/internal/api/api_app_link_sharing_test.go @@ -112,10 +112,12 @@ func TestAPairingRequestThatNamesNoRoleIsRefused(t *testing.T) { } // The same request with its role named still works, or the rule above - // would be indistinguishable from a broken endpoint. + // would be indistinguishable from a broken endpoint. Owner mint is + // loopback (or a house-password proof), not any LAN address. w := httptest.NewRecorder() - s.handleAppLinkPairing(w, localRequest( - http.MethodPost, "/api/app-link/pairing", `{"role":"owner"}`)) + atBox := localRequest(http.MethodPost, "/api/app-link/pairing", `{"role":"owner"}`) + atBox.RemoteAddr = "127.0.0.1:1234" + s.handleAppLinkPairing(w, atBox) if w.Code != http.StatusOK { t.Fatalf("naming the role got %d, want 200: %s", w.Code, w.Body.String()) } @@ -158,8 +160,9 @@ func TestABoxCodeIsTextAndAQRCodeIsNot(t *testing.T) { // appear in the field a screen would print as text. Decoded into a fresh // value, because an absent field leaves whatever was there before. w = httptest.NewRecorder() - s.handleAppLinkPairing(w, localRequest( - http.MethodPost, "/api/app-link/pairing", `{"role":"owner"}`)) + atBox := localRequest(http.MethodPost, "/api/app-link/pairing", `{"role":"owner"}`) + atBox.RemoteAddr = "127.0.0.1:1234" + s.handleAppLinkPairing(w, atBox) var scanned appLinkPairing if err := json.Unmarshal(w.Body.Bytes(), &scanned); err != nil { t.Fatalf("decoding the answer: %v", err) diff --git a/go/internal/api/api_app_link_test.go b/go/internal/api/api_app_link_test.go index e905d987f..9a6af5f71 100644 --- a/go/internal/api/api_app_link_test.go +++ b/go/internal/api/api_app_link_test.go @@ -93,8 +93,12 @@ func (s *stubEnroller) AuthorisedCount() int { return 2 } // none is refused now, because a default at this endpoint decides who owns a // house. func pairingRequest(host, remote string, headers map[string]string) *http.Request { + return pairingRequestRole(host, remote, headers, "owner") +} + +func pairingRequestRole(host, remote string, headers map[string]string, role string) *http.Request { r := httptest.NewRequest(http.MethodPost, "/api/app-link/pairing", - strings.NewReader(`{"role":"owner"}`)) + strings.NewReader(`{"role":"`+role+`"}`)) r.Header.Set("Content-Type", "application/json") r.Host = host r.RemoteAddr = remote @@ -141,13 +145,43 @@ func TestPairingIsLocalOnly(t *testing.T) { } } -func TestPairingFromTheLAN(t *testing.T) { +func TestViewerPairingFromTheLAN(t *testing.T) { + enroll := &stubEnroller{} + s := New(&Deps{AppEnroll: enroll}) + + w := httptest.NewRecorder() + s.handleAppLinkPairing(w, pairingRequestRole("192.168.1.1", "192.168.1.5:1234", nil, "viewer")) + + if w.Code != http.StatusOK { + t.Fatalf("got %d, want 200: %s", w.Code, w.Body.String()) + } + if enroll.minted != 1 { + t.Fatalf("minted %d codes, want 1", enroll.minted) + } +} + +func TestOwnerPairingFromTheLANIsRefused(t *testing.T) { enroll := &stubEnroller{} s := New(&Deps{AppEnroll: enroll}) w := httptest.NewRecorder() s.handleAppLinkPairing(w, pairingRequest("192.168.1.1", "192.168.1.5:1234", nil)) + if w.Code != http.StatusForbidden { + t.Fatalf("got %d, want 403: %s", w.Code, w.Body.String()) + } + if enroll.minted != 0 { + t.Fatalf("minted %d owner codes from the open LAN", enroll.minted) + } +} + +func TestOwnerPairingFromLoopback(t *testing.T) { + enroll := &stubEnroller{} + s := New(&Deps{AppEnroll: enroll}) + + w := httptest.NewRecorder() + s.handleAppLinkPairing(w, pairingRequest("127.0.0.1", "127.0.0.1:1234", nil)) + if w.Code != http.StatusOK { t.Fatalf("got %d, want 200: %s", w.Code, w.Body.String()) }