Repository navigation
fix(changes): bind diff page tokens to the commits their sides reach - #1010
Conversation
Diff page tokens are checksummed, not signed, and a continuation reopened the commit ids a token carried after re-authorizing only the request's sides, so a re-signed token could read the changes of a branch the caller's policy does not allow. A continuation now reopens a token commit only when its side resolves to it now or, for a side that moves (a branch head, the branch form's merge base), when the caller could name the commit as a side of its own. A side spelled as a commit reaches only that commit. Unknown and unreadable commits answer the same 400 before any snapshot opens. GET /diff, GET /branches/diff and the branch diff statement share one read check, so the branch form's continuations are checked too.
Review of 638e874 (merge base 864495a)Recommendation: request changes for one small blocking item (doc accuracy). The security fix is correct, it is at the right layer, and the regression proof holds. This review posts as comments because this tool cannot submit a review state. Please treat the blocking item as "changes requested". Why, what, how, impact
Blocking
Optional (4)
Contract and affected class
Trade-offs and liability
Regression proofGQT cannot express this contract. The served GQT target sends no bearer token and applies no policy, and the format cannot edit and re-checksum a token. The cheapest owner that sees the contract is the server suite
Commands (each side in its own I checked that the head-only refusal text is in the head binary and not in the merge-base binary. With a shared target directory, Cargo reused the head engine for the base build. I discarded that run. The two probes in the inline comments were temporary tests. I appended them to CI on 638e874: 33 checks, 28 passed, 5 skipped (release and vocabulary jobs), 0 failed. This includes Limits
|
…hrough them The check that a diff page token's commits are reached by the request's sides now runs on every door, the embedded diff_page included, under an allow-all read decision there. A moved branch side now also reaches an earlier head of its branch: walking first parents from the branch's head through commits that branch published, within 1,024 commits. A fork's first head, which main published, is found this way for a reader who cannot read main; a commit below it is not. Each token commit opens through what reached it: the side's resolved snapshot, the branch it headed, or its id. A continuation therefore reads what its first page reads; reopening a side's head by id had asked every newer live branch's __manifest for it. changes_cost pins both forms flat in live branches and no costlier than the first page.
…the DST cost golden Two-point and branch diffs beside one and nine idle branches, each on a reopened handle: a first page and its continuation. The rows match at both sizes and the continuation reads no more than its first page. labeled_cost_rows_of reads several labels from one run.
What & why
Diff page tokens are checksummed, not signed. Before this change, a diff continuation re-authorized the sides the request names, then reopened whichever commit ids the token carried. So a caller could take a token from a diff it may read, re-sign it naming commits of a branch its policy does not allow, and read that branch's entity changes. A server test showed this returning
200on bothGET /diffandGET /branches/diff.A continuation now reopens a token commit only when the request's side reaches it:
readon the branch that authored it). An earlier head is found by walking first parents from the branch's head through commits that branch published, within 1,024 commits, so a fork's first head (whichmainpublished) is still reached, and nothing below it is.A side spelled as a commit id reaches only that commit. Unknown and unreadable commits get the same
400(change cursor rejected: …), and the refusal comes before any snapshot opens, so it reveals nothing about whether a commit exists. A continuation after a branch moves is still answered, as the diff contract promises. The check runs on every door: served reads use the caller's policy, and the embeddeddiff_page(the CLI with--store, the embedded statement) uses an allow-all decision, so a token has one contract everywhere.Each token commit now opens through what reached it: the side's resolved snapshot, the branch it headed, or its id. Before, a continuation reopened its sides' heads by id, and a lookup that missed asked each newer live branch's
__manifestfor the commit.GET /diff,GET /branches/diffand thebranch diff … after pagestatement now share one read-check helper in the server, so the branch form gets the same check. Before, the branch form authorized its two branch names but gave the engine no read check, so its continuations reopened token commits unchecked.Backing issue / RFC
Refs #1009, which proposes the general fix: authenticated continuation tokens. This PR is the per-kind proof for diff tokens until then.
GET /difflanded after v0.13.0 and has not been released, so no release ever had the hole, and there is nofixedchangelog note.Checklist
docs/user/branching/changes.md)Local verification
On this branch at 7671578 (on
mainat 864495a):cargo fmt --all --check: cleancargo clippy --workspace --all-targets --locked -- -D warnings -W clippy::dbg_macro: cleanRUST_MIN_STACK=16777216 cargo test -p omnigraph-engine -p omnigraph-server --locked --test integration -- changes:: changes_cost:: auth_policy:: data_routes::: engine 65 passed, 1 ignored (an existing ignore); server 130 passedBefore rebasing onto current
main(the same change together with the commit-list paging PR):cargo test -p omnigraph-server --locked --test integration: 264 passedcargo test -p omnigraph-engine --locked --test integration -- changes:: lineage_projection:: warm_read_cost:: changes_cost:: branching::: 146 passed, 1 ignored (an existing ignore)cargo test -p omnigraph-engine --locked --lib changes::token: 12 passed200for the re-signed tokens; with it restored, they pass.Not run: the canonical workspace test graph, the GQT corpus, and the S3/Azure suites. CI covers them.
Notes for reviewers
policy_commit_diff_forbidden_is_indistinguishable_from_unknownandpolicy_branch_review_needs_read_on_the_target) receive200for the re-signed tokens. With the check in place, they receive400.changes_cost::graph_diff_side_resolution_is_flat_in_live_branches. A continuation re-resolves its sides as the first page does; the branch form repeats the first page's merge-base capture. Both forms read 7__manifestobjects on the first page and 7 on the continuation, at 1 and at 9 idle branches. Before the open-through-the-side change, the continuation read 14 and then 22: it grew with live branches.400(restart the diff). This is the walk's bound.