Skip to content

UCAN attenuation and can() ignore Capability::constraints, so a child can drop its parent's restriction #425

Description

@PierrunoYT

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:

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    crate:coregitlawb-core — identity, certs, encrypt, DID/UCANkind:securityVulnerability fix or hardeningsev:mediumDegraded but workaround existssubsystem:identityDID/UCAN, http-sig auth, push authorization

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions