From 0473357be95fa748e45bf0b1f2e0475c82cb277a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 31 Jul 2026 14:35:34 +0200 Subject: [PATCH] fix(api): require auth for sensitive reads --- .changeset/protect-sensitive-api-reads.md | 5 +++ go/internal/api/security.go | 15 ++++--- go/internal/api/security_test.go | 53 +++++++++++++++-------- 3 files changed, 49 insertions(+), 24 deletions(-) create mode 100644 .changeset/protect-sensitive-api-reads.md diff --git a/.changeset/protect-sensitive-api-reads.md b/.changeset/protect-sensitive-api-reads.md new file mode 100644 index 00000000..397b3848 --- /dev/null +++ b/.changeset/protect-sensitive-api-reads.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Require remote API authentication before listing or downloading backups or revealing managed CalDAV credentials. diff --git a/go/internal/api/security.go b/go/internal/api/security.go index 479c4a4c..09aad85d 100644 --- a/go/internal/api/security.go +++ b/go/internal/api/security.go @@ -9,7 +9,7 @@ import ( "strings" ) -// MutationPolicy is the trust boundary for state-changing HTTP requests. +// MutationPolicy is the trust boundary for protected HTTP requests. // Local LAN clients remain compatible without a token; public/FQDN access is // opt-in and must prove possession of Token. type MutationPolicy struct { @@ -18,9 +18,9 @@ type MutationPolicy struct { } // SecureMutations rejects browser cross-site writes, non-JSON request bodies, -// malformed Host/Origin metadata, and unauthenticated writes addressed through -// non-local hostnames. Semantically active GET/HEAD requests are protected too; -// ordinary read-only requests are intentionally unaffected. +// malformed Host/Origin metadata, and unauthenticated protected requests +// addressed through non-local hostnames. Semantically active and secret-bearing +// GET/HEAD requests are protected too; ordinary reads remain unaffected. func SecureMutations(next http.Handler, policy MutationPolicy) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !requiresMutationProtection(r) { @@ -40,7 +40,7 @@ func SecureMutations(next http.Handler, policy MutationPolicy) http.Handler { if policy.RequireTokenForRemote && (!isLocalAuthority(reqAuthority) || !isLocalClient(r.RemoteAddr)) { if strings.TrimSpace(policy.Token) == "" { writeJSON(w, http.StatusForbidden, map[string]string{ - "error": "remote API mutations are disabled; configure FTW_API_TOKEN or use a local address", + "error": "remote access to protected API routes is disabled; configure FTW_API_TOKEN or use a local address", }) return } @@ -66,6 +66,11 @@ func requiresMutationProtection(r *http.Request) bool { case http.MethodOptions: return false case http.MethodGet, http.MethodHead: + if r.URL.Path == "/api/caldav/credentials" || + r.URL.Path == "/api/backups" || + strings.HasPrefix(r.URL.Path, "/api/backups/") { + return true + } switch r.URL.Path { case "/api/scan", "/api/oauth/myuplink/start": return true diff --git a/go/internal/api/security_test.go b/go/internal/api/security_test.go index 866ccaac..f5db83e0 100644 --- a/go/internal/api/security_test.go +++ b/go/internal/api/security_test.go @@ -82,7 +82,7 @@ func TestSecureMutationsTreatsEveryUnsafeHTTPMethodAsMutation(t *testing.T) { } } -func TestSecureMutationsGuardsSemanticallyActiveReads(t *testing.T) { +func TestSecureMutationsGuardsProtectedReads(t *testing.T) { guarded := []struct { name string method string @@ -95,6 +95,12 @@ func TestSecureMutationsGuardsSemanticallyActiveReads(t *testing.T) { {name: "OAuth start", method: http.MethodGet, path: "/api/oauth/myuplink/start"}, {name: "OAuth start via HEAD", method: http.MethodHead, path: "/api/oauth/myuplink/start"}, {name: "OAuth callback via HEAD", method: http.MethodHead, path: "/api/oauth/myuplink/callback"}, + {name: "backup list", method: http.MethodGet, path: "/api/backups"}, + {name: "backup list via HEAD", method: http.MethodHead, path: "/api/backups"}, + {name: "backup download", method: http.MethodGet, path: "/api/backups/ftw-full-backup-20260731T120000Z.ftwbak"}, + {name: "backup download via HEAD", method: http.MethodHead, path: "/api/backups/ftw-full-backup-20260731T120000Z.ftwbak"}, + {name: "CalDAV credentials", method: http.MethodGet, path: "/api/caldav/credentials"}, + {name: "CalDAV credentials via HEAD", method: http.MethodHead, path: "/api/caldav/credentials"}, } for _, tc := range guarded { @@ -149,26 +155,35 @@ func TestSecureMutationsLeavesOrdinaryReadsAndOAuthCallbackCompatible(t *testing } } -func TestSecureMutationsRequiresRemoteTokenForSemanticallyActiveRead(t *testing.T) { +func TestSecureMutationsRequiresRemoteTokenForProtectedReads(t *testing.T) { policy := MutationPolicy{RequireTokenForRemote: true, Token: testMutationToken} - request := func(auth string) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, "https://ftw.example.com/api/scan", nil) - req.RemoteAddr = "192.168.1.10:43210" - req.Header.Set("Origin", "https://ftw.example.com") - req.Header.Set("Sec-Fetch-Site", "same-origin") - if auth != "" { - req.Header.Set("Authorization", auth) - } - rr := httptest.NewRecorder() - SecureMutations(statusHandler(http.StatusNoContent), policy).ServeHTTP(rr, req) - return rr - } + for _, path := range []string{ + "/api/scan", + "/api/backups", + "/api/backups/ftw-full-backup-20260731T120000Z.ftwbak", + "/api/caldav/credentials", + } { + t.Run(path, func(t *testing.T) { + request := func(auth string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "https://ftw.example.com"+path, nil) + req.RemoteAddr = "192.168.1.10:43210" + req.Header.Set("Origin", "https://ftw.example.com") + req.Header.Set("Sec-Fetch-Site", "same-origin") + if auth != "" { + req.Header.Set("Authorization", auth) + } + rr := httptest.NewRecorder() + SecureMutations(statusHandler(http.StatusNoContent), policy).ServeHTTP(rr, req) + return rr + } - if rr := request(""); rr.Code != http.StatusUnauthorized { - t.Fatalf("missing token status = %d, want 401", rr.Code) - } - if rr := request("Bearer " + testMutationToken); rr.Code != http.StatusNoContent { - t.Fatalf("valid token status = %d, want 204 (body=%s)", rr.Code, rr.Body.String()) + if rr := request(""); rr.Code != http.StatusUnauthorized { + t.Fatalf("missing token status = %d, want 401", rr.Code) + } + if rr := request("Bearer " + testMutationToken); rr.Code != http.StatusNoContent { + t.Fatalf("valid token status = %d, want 204 (body=%s)", rr.Code, rr.Body.String()) + } + }) } }