Artifact f1d78ef8fc8c70973c6c0b41641139a4c1547d1e01e6826b7a733ff394e50a0b:
- File
internal/sync/handler_uv_test.go
— part of check-in
[3a1014cf60]
at
2026-10-11 13:28:30
on branch trunk
— fix(sync): send content only to readers; never follow HTTPS to HTTP (security) (#297)
* fix(sync): send content only to requesters allowed to read it
The sync server answered `gimme HASH` with the artifact whether or not a pull card had been accepted, and answered `pragma uv-hash`, `uvgimme` and `uvigot` with the unversioned catalog and files for any requester, anonymous ones included.
Match fossil's page_xfer:
- gimme is answered only when a pull card was accepted, or in a clone, which fossil also treats as pulling. - pragma uv-hash sends the catalog only to a requester with Read (o, i or Admin). uv-push-ok goes only to a requester with y; other readers get uv-pull-only, as fossil sends. - uvgimme and uvigot send nothing to a requester without Read, and uvigot adopts a client's mtime only for a requester with y. - Table sync follows the same rules: xgimme needs an accepted pull and pragma xtable-hash needs Read. - pragma ci-lock takes or reports a lock only for a requester with Write, so an anonymous requester cannot learn who holds one.
Pragma handling moves into handlePragma to keep handleControlCard short.
GHSA-mvx3-6793-8x4r
* docs(changelog): note that gating ci-lock on Write departs from fossil
GHSA-mvx3-6793-8x4r
* fix(sync): follow HTTP redirects as fossil does, never HTTPS to HTTP
Both HTTP transports used net/http's default redirect policy. It follows a redirect from HTTPS to plain HTTP, so a 307 or 308 resent the request, login card included, in cleartext. It also turns a 301 or 302 into a GET without the body and stops after 10 redirects, neither of which fossil does.
PostXfer now follows redirects the way fossil's http_exchange (src/http.c) and draft §4 describe. It replaces the client's redirect policy for these requests, then:
- refuses a redirect from HTTPS to HTTP, and to any scheme but http(s); - re-POSTs the same body and Content-Type for 301, 302, 307 and 308; - strips one trailing /xfer from the new location, as fossil's loop does (the draft says "repeatedly"; the code strips once); - fails on the twentieth redirect (MAX_REDIRECTS 20).
HTTPTransport and NewHTTPTransport both use it, including with a client passed through WithHTTPClient.
GHSA-3f8x-8fr5-mr98 (user: [email protected], size: 4526) [annotate] [blame] [check-ins using]
package sync import ( "context" "testing" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/uv" "github.com/danmestas/go-libfossil/internal/xfer" ) func handleReq(t *testing.T, r *repo.Repo, cards ...xfer.Card) *xfer.Message { t.Helper() req := &xfer.Message{Cards: cards} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } return resp } func TestHandlerPragmaUVHash_Match(t *testing.T) { r := setupSyncTestRepo(t) if err := uv.EnsureSchema(r.DB()); err != nil { t.Fatalf("EnsureSchema: %v", err) } uv.Write(r.DB(), "test.txt", []byte("hello"), 100) h, _ := uv.ContentHash(r.DB()) // Send matching hash — should get no uvigot back. resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PragmaCard{Name: "uv-hash", Values: []string{h}}, ) for _, c := range resp.Cards { if _, ok := c.(*xfer.UVIGotCard); ok { t.Error("should not send uvigot when hashes match") } } } func TestHandlerPragmaUVHash_Mismatch(t *testing.T) { r := setupPushableTestRepo(t) if err := uv.EnsureSchema(r.DB()); err != nil { t.Fatalf("EnsureSchema: %v", err) } uv.Write(r.DB(), "a.txt", []byte("aaa"), 100) uv.Write(r.DB(), "b.txt", []byte("bbb"), 200) // Send wrong hash — should get uvigot for each file + pragma uv-push-ok. resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.PragmaCard{Name: "uv-hash", Values: []string{"wrong"}}, ) uvigots := cardsByType(resp, xfer.CardUVIGot) if len(uvigots) != 2 { t.Errorf("expected 2 uvigot cards, got %d", len(uvigots)) } pragmas := cardsByType(resp, xfer.CardPragma) found := false for _, c := range pragmas { if c.(*xfer.PragmaCard).Name == "uv-push-ok" { found = true } } if !found { t.Error("expected pragma uv-push-ok") } } func TestHandlerUVGimme(t *testing.T) { r := setupSyncTestRepo(t) if err := uv.EnsureSchema(r.DB()); err != nil { t.Fatalf("EnsureSchema: %v", err) } uv.Write(r.DB(), "doc.txt", []byte("document content"), 100) resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.UVGimmeCard{Name: "doc.txt"}, ) uvfiles := cardsByType(resp, xfer.CardUVFile) if len(uvfiles) != 1 { t.Fatalf("expected 1 uvfile, got %d", len(uvfiles)) } f := uvfiles[0].(*xfer.UVFileCard) if f.Name != "doc.txt" || string(f.Content) != "document content" { t.Errorf("uvfile = %+v", f) } } func TestHandlerUVFile_Accepted(t *testing.T) { r := setupPushableTestRepo(t) if err := uv.EnsureSchema(r.DB()); err != nil { t.Fatalf("EnsureSchema: %v", err) } resp := handleReq(t, r, &xfer.PushCard{ServerCode: "s", ProjectCode: "p"}, &xfer.UVFileCard{ Name: "new.txt", MTime: 100, Hash: "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d", // sha1("hello") Size: 5, Flags: 0, Content: []byte("hello"), }, ) // Should not have errors. for _, c := range resp.Cards { if e, ok := c.(*xfer.ErrorCard); ok { t.Errorf("unexpected error: %s", e.Message) } } // Verify stored. content, mtime, hash, err := uv.Read(r.DB(), "new.txt") if err != nil { t.Fatalf("Read: %v", err) } if string(content) != "hello" || mtime != 100 || hash == "" { t.Errorf("stored = content=%q mtime=%d hash=%q", content, mtime, hash) } } func TestHandlerUVFile_Rejected_NoPush(t *testing.T) { r := setupSyncTestRepo(t) if err := uv.EnsureSchema(r.DB()); err != nil { t.Fatalf("EnsureSchema: %v", err) } // No push card — uvfile should be rejected. resp := handleReq(t, r, &xfer.UVFileCard{ Name: "new.txt", MTime: 100, Hash: "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d", Size: 5, Flags: 0, Content: []byte("hello"), }, ) errors := cardsByType(resp, xfer.CardError) if len(errors) == 0 { t.Error("expected error card for uvfile without push") } } func TestHandlerUVIGot_ServerPulls(t *testing.T) { r := setupSyncTestRepo(t) if err := uv.EnsureSchema(r.DB()); err != nil { t.Fatalf("EnsureSchema: %v", err) } // Server has nothing — client announces a file via uvigot. resp := handleReq(t, r, &xfer.PullCard{ServerCode: "s", ProjectCode: "p"}, &xfer.UVIGotCard{Name: "client.txt", MTime: 100, Hash: "abc", Size: 10}, ) gimmes := cardsByType(resp, xfer.CardUVGimme) if len(gimmes) != 1 { t.Fatalf("expected 1 uvgimme, got %d", len(gimmes)) } if gimmes[0].(*xfer.UVGimmeCard).Name != "client.txt" { t.Errorf("wrong gimme name") } }