Artifact b83e5cb8909ddcce25ed1a48d959f1963e05fd4df5ef6e5ac65086ba9c4893ef:
- File
internal/sync/handler_uv.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: 5643) [annotate] [blame] [check-ins using]
package sync import ( "fmt" "github.com/danmestas/go-libfossil/internal/auth" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/uv" "github.com/danmestas/go-libfossil/internal/xfer" ) // handlePragmaUVHash answers pragma uv-hash as fossil's page_xfer does: a // requester with Read whose hash differs gets the catalog, headed by // uv-push-ok if it may write unversioned files (y) and uv-pull-only if not. // Anyone else gets nothing, and the pragma is spent either way. func (h *handler) handlePragmaUVHash(clientHash string) error { if h.uvCatalogSent { return nil } if !auth.CanRead(h.caps) { h.uvCatalogSent = true return nil } if err := uv.EnsureSchema(h.repo.DB()); err != nil { return fmt.Errorf("handler.handlePragmaUVHash: ensure schema: %w", err) } localHash, err := uv.ContentHash(h.repo.DB()) if err != nil { return fmt.Errorf("handler.handlePragmaUVHash: content hash: %w", err) } if localHash == clientHash { return nil // already in sync } h.uvCatalogSent = true if auth.CanPushUV(h.caps) { h.resp = append(h.resp, &xfer.PragmaCard{Name: "uv-push-ok"}) } else { h.resp = append(h.resp, &xfer.PragmaCard{Name: "uv-pull-only"}) } entries, err := uv.List(h.repo.DB()) if err != nil { return fmt.Errorf("handler.handlePragmaUVHash: list: %w", err) } for _, e := range entries { hashVal := e.Hash if hashVal == "" { hashVal = "-" } h.resp = append(h.resp, &xfer.UVIGotCard{ Name: e.Name, MTime: e.MTime, Hash: hashVal, Size: e.Size, }) } return nil } func (h *handler) handleUVIGot(c *xfer.UVIGotCard) error { if c == nil { panic("handler.handleUVIGot: c must not be nil") } // Fossil's server ignores uvigot; this one answers it, so it holds the // answer to the same rule as uvgimme: nothing for a requester without Read. if !auth.CanRead(h.caps) { return nil } if err := uv.EnsureSchema(h.repo.DB()); err != nil { return fmt.Errorf("handler.handleUVIGot: ensure schema: %w", err) } _, localMtime, localHash, err := uv.Read(h.repo.DB(), c.Name) if err != nil { return fmt.Errorf("handler.handleUVIGot: read %q: %w", c.Name, err) } status := uv.Status(localMtime, localHash, c.MTime, c.Hash) switch { case status == 0 || status == 1: h.resp = append(h.resp, &xfer.UVGimmeCard{Name: c.Name}) case status == 2: // Adopting the client's mtime is an unversioned write. if !auth.CanPushUV(h.caps) { return nil } if _, err := h.repo.DB().Exec("UPDATE unversioned SET mtime=? WHERE name=?", c.MTime, c.Name); err != nil { return fmt.Errorf("handler.handleUVIGot: update mtime %q: %w", c.Name, err) } if err := uv.InvalidateHash(h.repo.DB()); err != nil { return fmt.Errorf("handler.handleUVIGot: invalidate hash: %w", err) } case status == 4 || status == 5: if err := h.sendUVFile(c.Name); err != nil { return fmt.Errorf("handler.handleUVIGot: send %q: %w", c.Name, err) } } return nil } func (h *handler) handleUVGimme(c *xfer.UVGimmeCard) error { if c == nil { panic("handler.handleUVGimme: c must not be nil") } // Fossil 2.28 sends any uvgimme'd file to any requester. Its own client // asks only for files named in a uv-hash catalog, which needs Read, so // requiring Read here costs no interop and closes the direct read. if !auth.CanRead(h.caps) { return nil } if h.buggify != nil && h.buggify.Check("handler.handleUVGimme.skip", 0.05) { return nil } return h.sendUVFile(c.Name) } func (h *handler) sendUVFile(name string) error { if name == "" { panic("handler.sendUVFile: name must not be empty") } content, mtime, fileHash, err := uv.Read(h.repo.DB(), name) if err != nil { return fmt.Errorf("handler.sendUVFile: read %q: %w", name, err) } if fileHash == "" { h.resp = append(h.resp, &xfer.UVFileCard{ Name: name, MTime: mtime, Hash: "-", Size: 0, Flags: xfer.UVFlagDeletion, }) return nil } h.resp = append(h.resp, &xfer.UVFileCard{ Name: name, MTime: mtime, Hash: fileHash, Size: len(content), Flags: 0, Content: content, }) return nil } func (h *handler) handleUVFile(c *xfer.UVFileCard) error { if c == nil { panic("handler.handleUVFile: c must not be nil") } if !auth.CanPushUV(h.caps) { h.resp = append(h.resp, &xfer.ErrorCard{ Message: fmt.Sprintf("uvfile %s denied: insufficient capabilities", c.Name), }) return nil } if !h.pushOK { h.resp = append(h.resp, &xfer.ErrorCard{ Message: fmt.Sprintf("uvfile %s rejected: no push card", c.Name), }) return nil } if h.buggify != nil && h.buggify.Check("handler.handleUVFile.drop", 0.03) { return nil } if err := uv.EnsureSchema(h.repo.DB()); err != nil { return fmt.Errorf("handler.handleUVFile: ensure schema: %w", err) } // Validate hash if content present. if c.Flags&xfer.UVFlagNoPayload == 0 { if c.Content == nil { panic("handler.handleUVFile: flags indicate payload but Content is nil") } computed := hash.ContentHash(c.Content, c.Hash) if computed != c.Hash { h.resp = append(h.resp, &xfer.ErrorCard{ Message: fmt.Sprintf("uvfile %s: hash mismatch", c.Name), }) return nil } } // Double-check status. _, localMtime, localHash, err := uv.Read(h.repo.DB(), c.Name) if err != nil { return fmt.Errorf("handler.handleUVFile: read %q: %w", c.Name, err) } status := uv.Status(localMtime, localHash, c.MTime, c.Hash) if status >= 2 { return nil } // Apply. if c.Hash == "-" { return uv.Delete(h.repo.DB(), c.Name, c.MTime) } if c.Content != nil { return uv.Write(h.repo.DB(), c.Name, c.Content, c.MTime) } return nil }