Artifact 1ec15400b478dc1f6e18088869d8f3430d4e9e67c3f230b42e4b97e8f451957c:
- File
db/schema.go
— part of check-in
[3a6126c3e9]
at
2026-10-11 01:34:31
on branch trunk
— fix: harden sync and repository defaults (security) (#282)
* fix(xfer): reject non-decimal card sizes and bound payload memory
A payload card's size was parsed with strconv.Atoi, so a peer sending `file HASH -1` reached readPayload's negative-size assertion and panicked the client or server decoding the message, and a signed size such as `+5` was accepted. Sizes now go through parseSize, which follows the sync draft's `decimal = 1*DIGIT` grammar and fossil's 32-bit limit and returns a decoding error for anything else. uvfile FLAGS use the same parser, so negative FLAGS are rejected as the draft requires. The assertion stays, now unreachable from peer input.
readPayload no longer allocates the declared size up front: its buffer grows with the bytes read, so a large size over a short message fails after allocating about what was sent.
A cfile payload may inflate only to its USIZE (a margin above it for a delta) and not past its 4-byte size prefix, and all cfile payloads in one message share a MaxCFileInflateBytes (1 GiB) budget, so a few megabytes of zlib cannot inflate to tens of gigabytes.
FuzzDecode exercises the decoder over both framings.
* fix(sync): refuse malformed xrow payloads and cap HTTP reply bodies
An xrow payload or xdelete PKData from a peer was decoded into a map and used as is. JSON null gave a nil map that reached coerceJSONNumbers' assertion, and a primary-key value of the wrong type or a nested value reached normalizeValue's, panicking the client or server. Both directions now decode through decodeRowJSON: one JSON object, nothing after it, and repo.ValidateRow's rules, which require scalar values and primary-key values that PKHash can normalize. A client refuses an xrow for a table it has not registered. Column types match case-insensitively, as table creation accepts them.
Both HTTP transports read replies through xfer.ReadBody, which stops at MaxBodyBytes (MaxDecompressedBytes plus framing headroom) and returns ErrBodyTooLarge, untagged for retry, instead of reading an endless body into memory.
FuzzTableSyncPayload drives the server handler and client over arbitrary payloads.
* fix(sync): check synced-table rows and identifiers before building SQL
repo.UpsertXRow wrote a row's map keys into its INSERT as column names, and the rows of an xrow payload or xdelete PKData come from the peer, so a key such as "val, mtime) VALUES(...) --" chose the statement's columns and values, and a key could select from other tables into a synced one. This held on the server for pushed rows and tombstones, and on the client for a server's rows.
A peer's row must now use exactly the table's declared columns: repo.ValidateRow refuses any other key, which also keeps a peer from setting _owner. UpsertXRow no longer trusts its caller: it reads the table's registered schema and refuses any key it does not declare, apart from _owner on an ownership-mode table. ListXRows, LookupXRowOwner and DeleteXRowByPKHash validate the table name and the column names they interpolate, so no synced-table identifier reaches SQL text unchecked.
* fix(sync): answer reqconfig only with fossil's transferable config
The sync server answered reqconfig NAME with any row of the config table, for any requester, including one with no capabilities, so project-code, server-code and every local setting could be read by anyone who could reach the sync endpoint.
handleReqConfig now follows fossil's page_xfer and configure.c. It answers only a /GROUP name, only for a requester with Read, matches the group by prefix in fossil's table order, and sends only the names fossil transfers in that group, in fossil's encoding. The user and subscriber tables also need Admin, which counts only for a verified login, and the concealed table needs RdAddr. A refused request gets no cards and no error.
The groups, their names and their capability rules live in one place, config_groups.go. A differential test answers the same requests with fossil's own server and with HandleSync on the same repository and requires identical config cards.
* test(sync): keep fossil http from leaving a backoffice process
* fix(sync): check the login nonce against the request body
The server verified a login card's signature over its nonce but never checked that the nonce was the SHA-1 of the request body after the login line, as fossil's check_tail_hash does (draft-fossil-sync-protocol-00 §6.2). A login card captured from one request therefore authenticated any other request body.
The decoder now records, for each login card, the SHA-1 of the decompressed bytes after the card's line, and VerifyLogin refuses a card whose nonce does not match it. A login card that was never decoded has no tail and is refused.
Login handling otherwise follows fossil's page_xfer and check_login: - a refused login ends the exchange with "error login failed" instead of continuing as nobody; - a second login card is refused with "multiple login cards"; - a successful login adds the user's capabilities to nobody's instead of replacing them; - nobody and anonymous log in without a password, and the reserved developer and reader logins and accounts with an empty password cannot be logged into.
Fixes GHSA-2g5p-q5f3-477p.
* fix(repo): seed nobody with fossil's default capabilities
A new repository seeded the nobody user with "cghijknorswy", so every request without a login card could push check-ins and unversioned files, write wiki pages and tickets, and held Setup. Seed the four built-in users `fossil new` creates, with fossil's capabilities: nobody "gjorz", anonymous "hz", developer "ei" and reader "kptw".
Setup and Admin now include check-in, read and clone, as they do in fossil, so the creating user can sync once given a password. A login to an account with no stored password is refused, as fossil's check_login refuses it; the creator starts with none, and the signature would otherwise be the SHA-1 of the nonce alone.
Tests that push without credentials grant nobody push explicitly. Existing repositories keep their stored capabilities.
* test(auth): reserved-login test updates the seeded developer and reader rows
New repositories now seed developer and reader as fossil does, so the test gives the existing rows a password instead of inserting them.
* docs(changelog): group the sync hardening fixes under Security
* test(sync): reqconfig tests sign their logins over the request body
The login nonce now has to cover the request body, so the reqconfig tests build their admin requests with signedRequest instead of a bare login card. (user: [email protected], size: 9984) [annotate] [blame] [check-ins using]
package db import ( "encoding/hex" "fmt" "strconv" "github.com/danmestas/go-libfossil/simio" ) // Fossil's hash-policy config values, in Fossil's own order (see // `fossil help hash-policy`). The row holds the integer as text. // // Naming follows from the value: SHA1 below HashPolicySHA3, SHA3 at or // above it, except HashPolicyAuto, which names with SHA1 until a SHA3 // artifact enters the repo. From HashPolicySHA3Only up, an artifact already // stored under a legacy SHA1 name is not reused. const ( HashPolicySHA1 = 0 HashPolicyAuto = 1 HashPolicySHA3 = 2 HashPolicySHA3Only = 3 HashPolicyShunSHA1 = 4 ) const schemaRepo1 = ` CREATE TABLE blob( rid INTEGER PRIMARY KEY, rcvid INTEGER, size INTEGER, uuid TEXT UNIQUE NOT NULL, content BLOB, CHECK( length(uuid)>=40 AND rid>0 ) ); CREATE TABLE delta( rid INTEGER PRIMARY KEY, srcid INTEGER NOT NULL REFERENCES blob ); CREATE INDEX delta_i1 ON delta(srcid); CREATE TABLE rcvfrom( rcvid INTEGER PRIMARY KEY, uid INTEGER REFERENCES user, mtime DATETIME, nonce TEXT UNIQUE, ipaddr TEXT ); CREATE TABLE user( uid INTEGER PRIMARY KEY, login TEXT UNIQUE, pw TEXT, cap TEXT, cookie TEXT, ipaddr TEXT, cexpire DATETIME, info TEXT, mtime DATE, photo BLOB ); CREATE TABLE config( name TEXT PRIMARY KEY NOT NULL, value CLOB, mtime DATE, CHECK( typeof(name)='text' AND length(name)>=1 ) ) WITHOUT ROWID; CREATE TABLE shun( uuid TEXT PRIMARY KEY, mtime DATE, scom TEXT ) WITHOUT ROWID; CREATE TABLE private(rid INTEGER PRIMARY KEY); CREATE TABLE reportfmt( rn INTEGER PRIMARY KEY, owner TEXT, title TEXT UNIQUE, mtime DATE, cols TEXT, sqlcode TEXT ); CREATE TABLE concealed( hash TEXT PRIMARY KEY, mtime DATE, content TEXT ) WITHOUT ROWID; PRAGMA application_id=252006673; ` const schemaRepo2 = ` CREATE TABLE filename( fnid INTEGER PRIMARY KEY, name TEXT UNIQUE ); CREATE TABLE mlink( mid INTEGER, fid INTEGER, pmid INTEGER, pid INTEGER, fnid INTEGER REFERENCES filename, pfnid INTEGER, mperm INTEGER, isaux BOOLEAN DEFAULT 0 ); CREATE INDEX mlink_i1 ON mlink(mid); CREATE INDEX mlink_i2 ON mlink(fnid); CREATE INDEX mlink_i3 ON mlink(fid); CREATE INDEX mlink_i4 ON mlink(pid); CREATE TABLE plink( pid INTEGER REFERENCES blob, cid INTEGER REFERENCES blob, isprim BOOLEAN, mtime DATETIME, baseid INTEGER REFERENCES blob, UNIQUE(pid, cid) ); CREATE INDEX plink_i2 ON plink(cid,pid); CREATE TABLE leaf(rid INTEGER PRIMARY KEY); CREATE TABLE event( type TEXT, mtime DATETIME, objid INTEGER PRIMARY KEY, tagid INTEGER, uid INTEGER REFERENCES user, bgcolor TEXT, euser TEXT, user TEXT, ecomment TEXT, comment TEXT, brief TEXT, omtime DATETIME ); CREATE INDEX event_i1 ON event(mtime); CREATE TABLE phantom( rid INTEGER PRIMARY KEY ); CREATE TABLE orphan( rid INTEGER PRIMARY KEY, baseline INTEGER ); CREATE INDEX orphan_baseline ON orphan(baseline); CREATE TABLE unclustered( rid INTEGER PRIMARY KEY ); CREATE TABLE unsent( rid INTEGER PRIMARY KEY ); CREATE TABLE tag( tagid INTEGER PRIMARY KEY, tagname TEXT UNIQUE ); INSERT INTO tag VALUES(1, 'bgcolor'); INSERT INTO tag VALUES(2, 'comment'); INSERT INTO tag VALUES(3, 'user'); INSERT INTO tag VALUES(4, 'date'); INSERT INTO tag VALUES(5, 'hidden'); INSERT INTO tag VALUES(6, 'private'); INSERT INTO tag VALUES(7, 'cluster'); INSERT INTO tag VALUES(8, 'branch'); INSERT INTO tag VALUES(9, 'closed'); INSERT INTO tag VALUES(10,'parent'); INSERT INTO tag VALUES(11,'note'); CREATE TABLE tagxref( tagid INTEGER REFERENCES tag, tagtype INTEGER, srcid INTEGER REFERENCES blob, origid INTEGER REFERENCES blob, value TEXT, mtime TIMESTAMP, rid INTEGER REFERENCE blob, UNIQUE(rid, tagid) ); CREATE INDEX tagxref_i1 ON tagxref(tagid, mtime); CREATE TABLE backlink( target TEXT, srctype INT, srcid INT, mtime TIMESTAMP, UNIQUE(target, srctype, srcid) ); CREATE INDEX backlink_src ON backlink(srcid, srctype); CREATE TABLE attachment( attachid INTEGER PRIMARY KEY, isLatest BOOLEAN DEFAULT 0, mtime TIMESTAMP, src TEXT, target TEXT, filename TEXT, comment TEXT, user TEXT ); CREATE INDEX attachment_idx1 ON attachment(target, filename, mtime); CREATE INDEX attachment_idx2 ON attachment(src); CREATE TABLE ticket( tkt_id INTEGER PRIMARY KEY, tkt_uuid TEXT UNIQUE, tkt_mtime DATE, tkt_ctime DATE, type TEXT, status TEXT, subsystem TEXT, priority TEXT, severity TEXT, foundin TEXT, private_contact TEXT, resolution TEXT, title TEXT, comment TEXT ); CREATE TABLE ticketchng( tkt_id INTEGER REFERENCES ticket, tkt_rid INTEGER REFERENCES blob, tkt_mtime DATE, tkt_user TEXT, login TEXT, username TEXT, mimetype TEXT, icomment TEXT ); CREATE INDEX ticketchng_idx1 ON ticketchng(tkt_id, tkt_mtime); CREATE TABLE cherrypick( parentid INT, childid INT, isExclude BOOLEAN DEFAULT false, PRIMARY KEY(parentid, childid) ) WITHOUT ROWID; CREATE INDEX cherrypick_cid ON cherrypick(childid); CREATE TABLE forumpost( fpid INTEGER PRIMARY KEY, froot INT, fprev INT, firt INT, fmtime REAL ); CREATE INDEX forumpost_froot ON forumpost(froot); INSERT INTO rcvfrom(rcvid, uid, mtime, nonce, ipaddr) VALUES(1, 0, 0, NULL, NULL); ` func CreateRepoSchema(d *DB) error { if d == nil { panic("db.CreateRepoSchema: d must not be nil") } _, err := d.Exec(schemaRepo1) if err != nil { return fmt.Errorf("schema repo1: %w", err) } _, err = d.Exec(schemaRepo2) if err != nil { return fmt.Errorf("schema repo2: %w", err) } return nil } func SeedUser(d *DB, login string) error { if d == nil { panic("db.SeedUser: d must not be nil") } if login == "" { panic("db.SeedUser: login must not be empty") } _, err := d.Exec( "INSERT OR IGNORE INTO user(uid, login, pw, cap, info) VALUES(1, ?, '', 's', '')", login, ) return err } // SeedNobody inserts a "nobody" user with the given capabilities. // This controls anonymous access policy for the repo. A new repository // gets fossil's built-in users from SeedDefaultUsers instead. func SeedNobody(d *DB, caps string) error { if d == nil { panic("db.SeedNobody: d must not be nil") } _, err := d.Exec( "INSERT OR IGNORE INTO user(login, pw, cap, info) VALUES('nobody', '', ?, '')", caps, ) return err } // SeedDefaultUsers inserts the four built-in users `fossil new` creates, with // fossil's capabilities (src/db.c db_create_default_users). nobody's set is // what every unauthenticated sync request is granted, so it is clone, read // and zip only: check-in rights must be granted explicitly. developer and // reader are templates other users inherit through v and u. anonymous gets a // random password, as fossil gives it; the others have none, so no login can // reach them (VerifyLogin requires a stored password). func SeedDefaultUsers(d *DB, rng simio.Rand) error { if d == nil { panic("db.SeedDefaultUsers: d must not be nil") } if rng == nil { panic("db.SeedDefaultUsers: rng must not be nil") } anonPW, err := randomHex(8, rng) if err != nil { return fmt.Errorf("db.SeedDefaultUsers: %w", err) } _, err = d.Exec( "INSERT OR IGNORE INTO user(login, pw, cap, info) VALUES"+ "('anonymous', upper(?), 'hz', 'Anon'),"+ "('nobody', '', 'gjorz', 'Nobody'),"+ "('developer', '', 'ei', 'Dev'),"+ "('reader', '', 'kptw', 'Reader')", anonPW, ) return err } // SeedConfig seeds the config table with project-code, server-code, the // schema markers, and hash-policy. If projectCode is empty, a fresh 40-char // lowercase-hex project-code is generated from rng. If non-empty, it must // match ^[0-9a-f]{40}$; otherwise an error is returned and no rows are // written. Server-code is always generated. // // hash-policy is seeded to sha3, which is what `fossil init` writes and so // what a repo created here has to say to name its artifacts the same way. // A clone is the exception: it adopts its source's policy, not this one, so // sync.Clone drops the row the same way it drops project-code. func SeedConfig(d *DB, rng simio.Rand, projectCode string) error { if d == nil { panic("db.SeedConfig: d must not be nil") } if rng == nil { panic("db.SeedConfig: rng must not be nil") } var projCode string if projectCode != "" { if !isProjectCodeHex(projectCode) { return fmt.Errorf("db.SeedConfig: invalid project-code %q: must be 40-char lowercase hex", projectCode) } projCode = projectCode } else { var err error projCode, err = randomHex(20, rng) if err != nil { return fmt.Errorf("generating project-code: %w", err) } } serverCode, err := randomHex(20, rng) if err != nil { return fmt.Errorf("generating server-code: %w", err) } stmts := []struct { name, value string }{ {"project-code", projCode}, {"server-code", serverCode}, {"aux-schema", "2015-01-24"}, {"content-schema", "2"}, {"hash-policy", strconv.Itoa(HashPolicySHA3)}, } for _, s := range stmts { _, err := d.Exec( "INSERT INTO config(name, value, mtime) VALUES(?, ?, strftime('%s','now'))", s.name, s.value, ) if err != nil { return fmt.Errorf("seed config %q: %w", s.name, err) } } _, err = d.Exec("INSERT OR REPLACE INTO rcvfrom(rcvid, uid, mtime, nonce, ipaddr) VALUES(1, 1, julianday('now'), NULL, NULL)") if err != nil { return fmt.Errorf("seed rcvfrom: %w", err) } return nil } func randomHex(nBytes int, rng simio.Rand) (string, error) { b := make([]byte, nBytes) if _, err := rng.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // isProjectCodeHex reports whether s matches ^[0-9a-f]{40}$ — the format // produced by randomHex(20, ...). Kept private; callers that need this // invariant should validate at their boundary. func isProjectCodeHex(s string) bool { if len(s) != 40 { return false } for i := 0; i < len(s); i++ { c := s[i] if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') { continue } return false } return true }