Artifact 2def13c09c8da54d0d76dc954761ed74149c42fa41cfe1b35b2c5fe6a06a1742:
- File
internal/sync/ckin_lock_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: 5317) [annotate] [blame] [check-ins using]
package sync import ( "context" "path/filepath" "testing" "time" "github.com/danmestas/go-libfossil/internal/repo" _ "github.com/danmestas/go-libfossil/internal/testdriver" "github.com/danmestas/go-libfossil/internal/xfer" "github.com/danmestas/go-libfossil/simio" ) func newCkinLockTestRepo(t *testing.T) *repo.Repo { t.Helper() path := filepath.Join(t.TempDir(), "test.fossil") r, err := repo.Create(path, "test", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } t.Cleanup(func() { r.Close() }) return r } // insertLeafBlob inserts a blob and marks it as a leaf, returning the UUID. func insertLeafBlob(t *testing.T, r *repo.Repo, uuid string) { t.Helper() d := r.DB() d.Exec("INSERT INTO blob(uuid, size, content) VALUES(?, 0, X'')", uuid) var rid int64 if err := d.QueryRow("SELECT rid FROM blob WHERE uuid=?", uuid).Scan(&rid); err != nil { t.Fatalf("insertLeafBlob: %v", err) } d.Exec("INSERT INTO leaf(rid) VALUES(?)", rid) } func TestCkinLock_Acquire(t *testing.T) { r := newCkinLockTestRepo(t) uuid := "acq00000000000000000000000000000000000000" insertLeafBlob(t, r, uuid) fail := processCkinLock(r.DB(), uuid, "client-A", "alice", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("expected nil, got CkinLockFail{HeldBy: %q}", fail.HeldBy) } } func TestCkinLock_SameClient(t *testing.T) { r := newCkinLockTestRepo(t) d := r.DB() uuid := "same0000000000000000000000000000000000000" insertLeafBlob(t, r, uuid) fail := processCkinLock(d, uuid, "client-A", "alice", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("first acquire failed: %+v", fail) } fail = processCkinLock(d, uuid, "client-A", "alice", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("same-client re-acquire should succeed, got: %+v", fail) } } func TestCkinLock_Conflict(t *testing.T) { r := newCkinLockTestRepo(t) d := r.DB() uuid := "conflict0000000000000000000000000000000000" insertLeafBlob(t, r, uuid) fail := processCkinLock(d, uuid, "client-A", "alice", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("first acquire failed: %+v", fail) } fail = processCkinLock(d, uuid, "client-B", "bob", DefaultCkinLockTimeout) if fail == nil { t.Fatal("expected CkinLockFail for different client, got nil") } if fail.HeldBy != "alice" { t.Errorf("HeldBy = %q, want %q", fail.HeldBy, "alice") } } func TestCkinLock_Expiry(t *testing.T) { r := newCkinLockTestRepo(t) d := r.DB() uuid := "exp00000000000000000000000000000000000000" insertLeafBlob(t, r, uuid) // Acquire lock for client-A. fail := processCkinLock(d, uuid, "client-A", "alice", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("first acquire failed: %+v", fail) } // Backdate the lock's mtime by 120 seconds so it appears stale. key := configKey(uuid) stale := time.Now().Unix() - 120 d.Exec("UPDATE config SET mtime=? WHERE name=?", stale, key) // Different client should now succeed because the lock is expired. fail = processCkinLock(d, uuid, "client-B", "bob", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("expected expired lock to be reaped, got: %+v", fail) } } func TestCkinLock_ParentNotLeaf(t *testing.T) { r := newCkinLockTestRepo(t) d := r.DB() // Insert a blob that is NOT in the leaf table. uuid := "deadbeef00000000000000000000000000000000" d.Exec("INSERT INTO blob(uuid, size, content) VALUES(?, 0, X'')", uuid) // Acquire lock for client-A on this non-leaf blob. fail := processCkinLock(d, uuid, "client-A", "alice", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("first acquire failed: %+v", fail) } // Different client should succeed because the parent is not a leaf. fail = processCkinLock(d, uuid, "client-B", "bob", DefaultCkinLockTimeout) if fail != nil { t.Fatalf("expected non-leaf parent lock to be cleaned, got: %+v", fail) } } func TestCkinLock_SyncRoundTrip(t *testing.T) { serverRepo := newCkinLockTestRepo(t) // Insert a leaf blob on the server so the lock doesn't get cleaned // up by expireStaleLocks (which removes locks for non-leaf parents). parentUUID := "abcd0000000000000000000000000000000000ab" insertLeafBlob(t, serverRepo, parentUUID) // Only a requester who may commit takes a lock. grantNobodyPush(t, serverRepo) transport := &MockTransport{ Handler: func(req *xfer.Message) *xfer.Message { resp, _ := HandleSync(context.Background(), serverRepo, req) return resp }, } clientRepo := newCkinLockTestRepo(t) result, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Pull: true, CkinLock: &CkinLockReq{ ParentUUID: parentUUID, ClientID: "client-1", }, }) if err != nil { t.Fatalf("Sync: %v", err) } if result.CkinLockFail != nil { t.Fatalf("unexpected lock fail: %+v", result.CkinLockFail) } // Second client should get lock-fail. result2, err := Sync(context.Background(), clientRepo, transport, SyncOpts{ Pull: true, CkinLock: &CkinLockReq{ ParentUUID: parentUUID, ClientID: "client-2", }, }) if err != nil { t.Fatalf("Sync 2: %v", err) } if result2.CkinLockFail == nil { t.Fatal("expected lock fail for second client") } // Default user with no login card is "nobody" if result2.CkinLockFail.HeldBy != "nobody" { t.Fatalf("HeldBy = %q, want %q", result2.CkinLockFail.HeldBy, "nobody") } }