Capability carries a constraints: Option<serde_json::Value> field with a public builder (ucan.rs:39), but neither of the two functions that decide authority ever reads it.
The two sites
Capability::is_attenuated_by (ucan.rs:52) compares resource and action only:
pub fn is_attenuated_by(&self, parent: &Capability) -> bool {
let resource_ok = parent.with == self.with || parent.with == "*";
let action_ok =
parent.can == self.can || parent.can == "*" || parent.can == caps::REPO_ADMIN;
resource_ok && action_ok
}
Ucan::can (ucan.rs:195) does the same, and its doc comment says explicitly that it mirrors the function above.
So a child capability may drop or rewrite its parent's constraints and still be a valid attenuation, and a constrained capability answers can() identically to an unconstrained one. verify_chain reports success in both cases.
Why this is a semantics gap rather than a live bypass today
On main at bfc44f926d08c0bf774e2c05dd76b245871294f1, Ucan::can has no call sites in crates/gitlawb-node — #331's description states this and I confirmed it. require_ucan_chain validates a presented token and discards the result, and push authorization runs through caller_authorized_to_push, which is an owner comparison. So nothing in the node currently acts on a constrained capability, and no current push bypass is being asserted here.
What is wrong today is narrower but still real: verify_chain returning Ok does not mean what a caller would reasonably read it to mean. A library consumer that delegates git/push restricted to one branch, and then checks the chain, gets success for a child that dropped the restriction. The type invites the mistake — with_constraints is public and documented — and nothing rejects or warns about a field the verifier ignores.
Why #331 makes this time-sensitive rather than theoretical
#331 is the change that starts consulting the capability: it anchors the chain to a root issuer and honours delegated git/push. The moment can() becomes load-bearing for authorization, ignoring constraints stops being a latent library gap and becomes the live semantics of push authorization.
#331 mitigates this from one direction by refusing constrained git/push grants at authorization time, which is the right conservative move. That is not the same as attenuation being correct — it closes one action on one path, while is_attenuated_by remains permissive for every other capability and for any future consumer.
Fix direction
Two viable shapes, and the choice is a design decision rather than a bug fix:
- Define the semantics. Decide what a constraint means per capability kind, and make
is_attenuated_by require that the child's constraints are at least as restrictive as the parent's. This needs a written rule for what "at least as restrictive" means over arbitrary JSON, which is the hard part and the reason to do it deliberately.
- Refuse what is not understood. Until (1) exists, treat a capability carrying
constraints as unverifiable: reject it in is_attenuated_by and return false from can(), rather than silently reading it as unrestricted. Fails in the safe direction and does not depend on settling the semantics first.
(2) is compatible with #331's approach and generalizes it. If (2) lands, with_constraints should carry a doc note saying constrained capabilities are currently refused rather than honoured, so the builder stops implying a feature that does not exist.
Property tests over attenuation chains would be worth having either way — the wildcard rules alone ("*" on the parent only, plus repo/admin acting as an action wildcard) have enough asymmetry to be worth pinning.
Validation status
Verified by reading ucan.rs and by confirming can() has no node-crate call sites on this commit. No exploit is claimed and none was attempted, because on this commit there is no authorization path to exploit.
Disclosure note
Originally submitted through private vulnerability reporting and filed publicly at the maintainer's direction. Filing in the open is appropriate here because there is no current bypass to disclose — the issue is what the API promises versus what it checks, and the fix needs design discussion.
Found during an external audit pass.
Capabilitycarries aconstraints: Option<serde_json::Value>field with a public builder (ucan.rs:39), but neither of the two functions that decide authority ever reads it.The two sites
Capability::is_attenuated_by(ucan.rs:52) compares resource and action only:Ucan::can(ucan.rs:195) does the same, and its doc comment says explicitly that it mirrors the function above.So a child capability may drop or rewrite its parent's
constraintsand still be a valid attenuation, and a constrained capability answerscan()identically to an unconstrained one.verify_chainreports success in both cases.Why this is a semantics gap rather than a live bypass today
On
mainatbfc44f926d08c0bf774e2c05dd76b245871294f1,Ucan::canhas no call sites incrates/gitlawb-node— #331's description states this and I confirmed it.require_ucan_chainvalidates a presented token and discards the result, and push authorization runs throughcaller_authorized_to_push, which is an owner comparison. So nothing in the node currently acts on a constrained capability, and no current push bypass is being asserted here.What is wrong today is narrower but still real:
verify_chainreturningOkdoes not mean what a caller would reasonably read it to mean. A library consumer that delegatesgit/pushrestricted to one branch, and then checks the chain, gets success for a child that dropped the restriction. The type invites the mistake —with_constraintsis public and documented — and nothing rejects or warns about a field the verifier ignores.Why #331 makes this time-sensitive rather than theoretical
#331 is the change that starts consulting the capability: it anchors the chain to a root issuer and honours delegated
git/push. The momentcan()becomes load-bearing for authorization, ignoringconstraintsstops being a latent library gap and becomes the live semantics of push authorization.#331 mitigates this from one direction by refusing constrained
git/pushgrants at authorization time, which is the right conservative move. That is not the same as attenuation being correct — it closes one action on one path, whileis_attenuated_byremains permissive for every other capability and for any future consumer.Fix direction
Two viable shapes, and the choice is a design decision rather than a bug fix:
is_attenuated_byrequire that the child's constraints are at least as restrictive as the parent's. This needs a written rule for what "at least as restrictive" means over arbitrary JSON, which is the hard part and the reason to do it deliberately.constraintsas unverifiable: reject it inis_attenuated_byand returnfalsefromcan(), rather than silently reading it as unrestricted. Fails in the safe direction and does not depend on settling the semantics first.(2) is compatible with #331's approach and generalizes it. If (2) lands,
with_constraintsshould carry a doc note saying constrained capabilities are currently refused rather than honoured, so the builder stops implying a feature that does not exist.Property tests over attenuation chains would be worth having either way — the wildcard rules alone (
"*"on the parent only, plusrepo/adminacting as an action wildcard) have enough asymmetry to be worth pinning.Validation status
Verified by reading
ucan.rsand by confirmingcan()has no node-crate call sites on this commit. No exploit is claimed and none was attempted, because on this commit there is no authorization path to exploit.Disclosure note
Originally submitted through private vulnerability reporting and filed publicly at the maintainer's direction. Filing in the open is appropriate here because there is no current bypass to disclose — the issue is what the API promises versus what it checks, and the fix needs design discussion.
Found during an external audit pass.