Artifact b427eb2780e8336cec7afcc36469776bd3a161bf1ab4908551c8ed6ff6cf207f:
- File
internal/xfer/container.go
— part of check-in
[9db9b2ebae]
at
2026-08-16 02:18:13
on branch trunk
— security: close whitespace-card sync-gate bypass; write-op input 400s
Audit found a critical: scanRequest matched card keywords with a raw byte-prefix, so a push/login card indented by any Fossil-whitespace byte (SP/HTAB/VT/FF/CR) was invisible to the gate but still honored by the zit backend — an unauthenticated write to a gated repo. Now tokenizes exactly as zit's tokenizeCard (leading-whitespace-skipping), verified live. Also: gate GET edit/new pages (were ungated), reject control chars in wiki names (400 not 500), ticket.set no-fields 400 via new ops.ErrInvalid. (user: dmestas, size: 5385) [annotate] [blame] [check-ins using]
// container.go — the sync protocol's compressed message container and // the card peeks quarry's edge needs (sync RFC §4.1, §6): a 4-byte // big-endian pre-compression length followed by a zlib stream. Quarry // inspects requests only enough to gate pushes; payload semantics stay // zit's business. package xfer import ( "bytes" "compress/zlib" "crypto/sha1" "encoding/binary" "encoding/hex" "fmt" "io" "net/http" "net/url" "strings" ) // decodeContainer returns the decompressed body of an // application/x-fossil message. Malformed containers return the bytes // as-is (the RFC: the receiver retains the original buffer and applies // ordinary card processing to it). func decodeContainer(raw []byte) []byte { if len(raw) < 6 { return raw } capacity := binary.BigEndian.Uint32(raw[:4]) zr, err := zlib.NewReader(bytes.NewReader(raw[4:])) if err != nil { return raw } defer zr.Close() out, err := io.ReadAll(io.LimitReader(zr, int64(capacity)+1)) if err != nil || uint64(len(out)) > uint64(capacity) { return raw } return out } // encodeContainer wraps a plaintext message for the wire. func encodeContainer(body []byte) []byte { var buf bytes.Buffer var lenb [4]byte binary.BigEndian.PutUint32(lenb[:], uint32(len(body))) buf.Write(lenb[:]) zw := zlib.NewWriter(&buf) zw.Write(body) zw.Close() return buf.Bytes() } // pushRequest holds what the gate needs from a request: whether it // mutates, and the login card's parts with the nonce-covered region. type pushRequest struct { hasPush bool projectCode string // from the push card user string nonce string sig string covered []byte // bytes after the login line's LF, through end } // isFossilWS matches the sync protocol's card-token separators (§5.2: // SP, HTAB, VT, FF, CR — LF terminates the line, never separates). The // gate MUST tokenize exactly as zit's tokenizeCard does: a card the // backend honors but the gate misses is a full auth bypass. In // particular leading whitespace is skippable, so a card keyword is not // anchored at byte 0. func isFossilWS(r rune) bool { switch r { case ' ', '\t', 0x0b, 0x0c, '\r': return true } return false } // cardTokens tokenizes one card line the way zit does — runs of Fossil // whitespace (including a leading run) separate tokens. func cardTokens(line []byte) []string { return strings.FieldsFunc(string(line), isFossilWS) } // scanRequest reads the decompressed card stream. The RFC places the // login card first; zit emits it after the client-version pragma, so // the scan accepts a login card in any position (the nonce covers the // bytes after that card's LF either way). Push cards may appear // anywhere among the operation cards. Classification is by first token // after Fossil-whitespace skipping — never a raw byte-prefix, which an // indented card would defeat while the backend still honored it. func scanRequest(body []byte) pushRequest { var pr pushRequest rest := body for { line, tail, found := bytes.Cut(rest, []byte("\n")) f := cardTokens(line) if len(f) > 0 && f[0] == "login" { if len(f) == 4 { pr.user, pr.nonce, pr.sig = defossilize(f[1]), f[2], f[3] pr.covered = tail } break } if !found { break } rest = tail } for _, line := range bytes.Split(body, []byte("\n")) { f := cardTokens(line) if len(f) > 0 && f[0] == "push" { pr.hasPush = true if len(f) == 3 { pr.projectCode = f[2] } } } return pr } // cookieLogin parses the x-f-l-c cookie (RFC §6.5): the body-login // card with spaces as + and the USER percent-encoded. Its nonce covers // the entire decompressed body. func cookieLogin(req *http.Request, body []byte) (pushRequest, bool) { c, err := req.Cookie("x-f-l-c") if err != nil { return pushRequest{}, false } f := strings.Split(c.Value, "+") if len(f) != 4 || f[0] != "login" { return pushRequest{}, false } user, err := url.QueryUnescape(f[1]) if err != nil { return pushRequest{}, false } return pushRequest{user: defossilize(user), nonce: f[2], sig: f[3], covered: body}, true } // verifyLogin checks the RFC §6 chain: NONCE is SHA-1 over the covered // bytes; SIGNATURE is SHA-1 over nonce-hex || shared-secret-hex. func verifyLogin(pr pushRequest, secret string) bool { if pr.user == "" || len(pr.nonce) != 40 || len(pr.sig) != 40 { return false } nonce := sha1.Sum(pr.covered) if hex.EncodeToString(nonce[:]) != pr.nonce { return false } sig := sha1.Sum([]byte(pr.nonce + secret)) return hex.EncodeToString(sig[:]) == pr.sig } // SharedSecret derives the stored credential (RFC §6.3): SHA-1 of // "projectcode/user/password". Plaintext never persists. func SharedSecret(projectCode, user, password string) string { sum := sha1.Sum([]byte(projectCode + "/" + user + "/" + password)) return hex.EncodeToString(sum[:]) } // errorReply builds a complete application/x-fossil reply holding one // error card (message spaces fossilized as \s). func errorReply(msg string) []byte { return encodeContainer([]byte("error " + strings.ReplaceAll(msg, " ", `\s`) + "\n")) } // defossilize undoes \s escaping in a card token. func defossilize(s string) string { return strings.ReplaceAll(s, `\s`, " ") } // ErrNoProjectCode reports a handshake that produced no push card. var ErrNoProjectCode = fmt.Errorf("handshake reply carried no project code")