Skip to content

Fix missing expiration checks - #316

Merged
aojea merged 7 commits into
google:mainfrom
aojea:bug296
Aug 26, 2026
Merged

Fix missing expiration checks#316
aojea merged 7 commits into
google:mainfrom
aojea:bug296

Conversation

@aojea

@aojea aojea commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Thanks also to @kaisoz for digging deeper, there were several paths were the biscuit expiration time was not checked , hence allowing attacks based on that.

Fixes #296

aojea and others added 6 commits August 26, 2026 00:27
SamNode.Authorize built its authorizer without injecting the time(now)
fact or the ControlPlaneStaticTimeCheck, unlike identity.VerifyBiscuit.
An expired biscuit that still verified cryptographically was accepted
on the node dataplane (tool-invocation path) while being correctly
rejected by the generic verifier.

Fixes google#296.
…IDC token's expiry

Every mint site hardcoded the biscuit expiration() fact to the fixed
api.BiscuitTokenTTL (24h), with no way for an operator to shorten or
lengthen it, and no relationship to the OIDC token that authorized the
enrollment. A biscuit could therefore outlive the session that vouched
for it.

Add Options.BiscuitTTL (defaulting to api.BiscuitTokenTTL) wired to a
new --biscuit-ttl flag on sam-control-plane, and use it at every mint
site instead of the hardcoded constant. In the interactive OIDC
enrollment path, cap the minted expiry to min(BiscuitTTL, token.Expiry)
and report that same value back in EnrollResponse.Expiration (it
previously reported the OIDC token's own expiry, which the node uses
to schedule its proactive refresh and did not match the biscuit's
actual expiration() fact).
…admission

The google#296 fix inlined the time fact and the expiration check into
SamNode.Authorize, which closed the tool-invocation path but left the
divergence that caused the bug: each authorizer site decides for itself
whether expiry is checked, and expiry is a Datalog check over a fact, so
forgetting the fact silently accepts expired tokens.

Add identity.EnforceExpiration as the single place that pairs the
time(now) fact with ControlPlaneStaticTimeCheck, and route
VerifyBiscuitAndGetKey and SamNode.Authorize through it.

Use it to close a second instance of the same bug: SamNode.verifyBiscuit,
behind the /sam/auth/1.0.0 handshake, admitted peers into authPeers with
no expiry check at all. nodeRelayACL grants relay reserve and connect
rights off that set, so an expired biscuit bought relay access.

Also correct the ControlPlaneStaticTimeCheck doc comment: it verifies the
biscuit's own expiration() fact, not an OIDC token.
A biscuit must not outlive whatever vouched for it. Interactive
enrollment already caps the minted expiry at the OIDC token's, but
/refresh presents no live token at all: the node authenticates with a
challenge signature and the control plane replays the claims recorded at
enrollment. What vouches for it there is the session record, and
HandleRefresh gated on it without bounding the biscuit by it.

A node refreshing in the final minutes of its 90-day session therefore
walked away with a full --biscuit-ttl token, valid a further 24h by
default past the session it was supposed to end with.

Cap biscuitExpiry at nodeRecord.ExpiresAt, guarding the zero value so
bootstrap records, which deliberately carry no session deadline, keep
the configured TTL.

Cover the four combinations (OIDC token first, TTL first, session first,
no session) by reading the minted expiration() fact back out and
checking the wire fields that drive the node's refresh scheduling agree
with it. Also gofmt config.go, missed when BiscuitTTL was added.
Reproduce the reported flow against real components rather than
hand-built tokens: a real control plane mints a real biscuit over
/register with a short --biscuit-ttl, and the same token is then put to
both verification paths the issue found disagreeing, before and after
its expiry.

Before, both accept. After, both must reject. Reverting the fix fails
the test on the dataplane assertion alone, which is the asymmetry
reported.

Also pins EnrollResponse.Expiration to the biscuit's real expiration,
since the node schedules its proactive refresh from that field.
…banned peers from the relay ACL

Follow-up audit of google#296 found two more paths where a lapsed token still
buys access, both reached through VerifyAndExtractPeerID, which skipped
time checks unconditionally.

That exemption exists for /refresh, where it is correct: a node refreshes
because its token lapsed, so it has nothing unexpired to present, and the
handler bounds the request with the session record and a signed
challenge. But GET /policies used the same helper to authenticate a node,
so an expired-but-not-banned node kept reading the mesh policy.

Make expiry the default and the exemption explicit and named:
VerifyAndExtractPeerID now enforces it, VerifyExpiredAndExtractPeerID
does not, and only the refresh handler calls the latter. Neither adds a
policy, so both report ErrNoMatchingPolicy on success; the test pins that
a failed check still outranks it, which is what makes this work.

Separately, revocation did not reach the peer-admission handshake at all.
HandleAuthHandshake never consulted revokedPeers, and handleBannedEvent
closed the connection but left the peer in authPeers, which nodeRelayACL
reads. A banned peer therefore kept relay reserve and connect rights
until the process restarted, and could re-handshake to get them back.
Check the cache before admitting, and evict on ban.

Also document that VerifyBiscuitRole does not enforce expiry, and why its
callers are fine with that: they hold a freshly minted token, or they are
deciding whether to boot with an on-disk identity, where a lapsed token
should trigger a refresh rather than refuse to start.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a configurable Biscuit TTL (--biscuit-ttl) and ensures that a Biscuit's expiration is capped by the OIDC token's expiry or the OIDC session's end. It fixes a security issue where expired tokens were accepted on the node dataplane by implementing and enforcing expiration checks across all verification paths. Additionally, it drops prior admissions when a peer is banned to prevent revoked peers from retaining relay rights. Feedback on the changes highlights a security concern where expired peers might retain relay rights indefinitely in authPeers due to a lack of cleanup mechanisms, and suggests storing expiration times in authPeers to evict expired entries. It also recommends handling errors from AddFact and AddCheck within EnforceExpiration to prevent potential silent security bypasses.

Comment thread internal/node/node.go
continue
}

identity.EnforceExpiration(authorizer)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Security Issue: Expired peers retain relay rights indefinitely in authPeers

Currently, when a peer successfully authenticates via HandleAuthHandshake, it is stored in n.authPeers with a value of true (on line 1431, which is outside this diff). However, there is no mechanism to clean up or re-verify these entries when the peer's Biscuit token expires. As a result, once a peer has authenticated once, it retains relay rights indefinitely in nodeRelayACL (which only checks authPeers), completely bypassing the short-lived token expiration security model.

Suggested Fix:

  1. Modify verifyBiscuit to extract and return the Biscuit's expiration time.
  2. Store the expiration time in n.authPeers instead of true.
  3. Update nodeRelayACL (AllowReserve and AllowConnect) to check and delete expired entries.

Here is how you can implement this:

In verifyBiscuit (this function):

func (n *SamNode) verifyBiscuit(biscuitData []byte, remotePeer peer.ID) (*biscuit.Biscuit, time.Time, error) {
    // ... (unmarshal and loop)
        identity.EnforceExpiration(authorizer)
        authorizer.AddPolicy(api.AllowIfTruePolicy)

        if err := authorizer.Authorize(); err == nil {
            var exp time.Time
            rule := biscuit.Rule{
                Head: biscuit.Predicate{Name: "get_exp", IDs: []biscuit.Term{biscuit.Variable("e")}},
                Body: []biscuit.Predicate{{Name: api.FactExpiration, IDs: []biscuit.Term{biscuit.Variable("e")}}},
            }
            if facts, err := authorizer.Query(rule); err == nil && len(facts) > 0 && len(facts[0].IDs) > 0 {
                if date, ok := facts[0].IDs[0].(biscuit.Date); ok {
                    exp = time.Time(date)
                }
            }
            return b, exp, nil
        }
    // ...
}

In HandleAuthHandshake:

    b, exp, err := n.verifyBiscuit(exchange.Biscuit, remotePeer)
    // ...
    n.authPeers.Store(remotePeer, exp)

In nodeRelayACL (around line 118):

func (a *nodeRelayACL) AllowReserve(p peer.ID, addr multiaddr.Multiaddr) bool {
    val, ok := a.node.authPeers.Load(p)
    if !ok {
        return false
    }
    if exp, ok := val.(time.Time); ok && !exp.IsZero() && time.Now().After(exp) {
        a.node.authPeers.Delete(p)
        return false
    }
    return true
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and this is the same bug one layer up: enforcing expiry at admission is worth little if the admission itself never expires. Fixed in 61d2fc7.

Took the shape you suggested, with two changes:

  • verifyBiscuit returns an error rather than a zero time when it cannot extract an expiration. EnforceExpiration already requires an expiration() fact for the token to authorize at all, so a token that authorizes but carries no such fact is a contradiction, not a peer to admit with an open-ended entry.
  • The AllowReserve sketch falls open on a type mismatch: if exp, ok := val.(time.Time); ok && ... returns true when the assertion fails. That is not hypothetical here, since the old code stored true and the tests stored struct{}{}. The version I landed treats any non-time.Time value as a bug and denies, with a regression test for it.

Extraction lives in identity.ExpirationOf, next to EnforceExpiration, so expiry logic stays in one place, and it takes the earliest expiration() fact in case an attenuated token narrows it.

Comment on lines +55 to +63
func EnforceExpiration(authorizer biscuit.Authorizer) {
authorizer.AddFact(biscuit.Fact{
Predicate: biscuit.Predicate{
Name: api.FactTime,
IDs: []biscuit.Term{biscuit.Date(time.Now())},
},
})
authorizer.AddCheck(api.ControlPlaneStaticTimeCheck)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Defensive Programming: Unhandled errors from AddFact and AddCheck

The AddFact and AddCheck methods return an error. While they are highly unlikely to fail with programmatic inputs, ignoring these errors could lead to silent security bypasses if the authorizer is in an invalid state or if internal limits are exceeded.

Consider handling these errors or panicking to fail-closed:

func EnforceExpiration(authorizer biscuit.Authorizer) {
	if err := authorizer.AddFact(biscuit.Fact{
		Predicate: biscuit.Predicate{
			Name: api.FactTime,
			IDs:  []biscuit.Term{biscuit.Date(time.Now())},
		},
	}); err != nil {
		panic(fmt.Sprintf("failed to add time fact: %v", err))
	}
	if err := authorizer.AddCheck(api.ControlPlaneStaticTimeCheck); err != nil {
		panic(fmt.Sprintf("failed to add static time check: %v", err))
	}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is a false positive: Authorizer.AddFact and AddCheck return nothing, so there is no error to handle.

From biscuit-go/v2@v2.2.0/authorizer.go:

AddFact(fact Fact)
AddRule(rule Rule)
AddCheck(check Check)
AddPolicy(policy Policy)

The suggestion looks like a mix-up with Builder.AddAuthorityFact, which does return an error and which we do check at every call site. errcheck is one of the active linters, so an unchecked error here would not have got past make lint either.

Leaving EnforceExpiration as is. The suggested panic would compile only after inventing an error that cannot occur.

The handshake proves a peer's biscuit was valid at that instant, then
records the peer in authPeers. nodeRelayACL reads only that set, and
nothing ever removed an entry, so a single handshake bought relay reserve
and connect rights for the lifetime of the process. Enforcing expiry at
admission, as the previous commit did, is worth little if the admission
outlives the token it was based on.

Store the token's expiration alongside the admission and re-check it in
the ACL, evicting on the way out. verifyBiscuit returns that instant and
fails if it cannot extract one: EnforceExpiration already requires an
expiration() fact to authorize at all, so a token that authorizes but has
no such fact is a contradiction, not a peer to admit with a zero time.

A value of any other type is likewise treated as a bug and denied, rather
than falling through to allow.

Extraction lives in identity.ExpirationOf, next to EnforceExpiration, so
expiry stays in one place. It takes the earliest expiration() fact, since
an attenuated token may narrow it.

Raised in review of google#316.
@aojea

aojea commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a configurable Biscuit TTL and resolves a security vulnerability (#296) by ensuring that Biscuit token expiration is strictly enforced on the node dataplane. It adds the EnforceExpiration helper to inject the current time and static time checks into the authorizer. The review feedback highlights critical compilation errors due to incorrect field access on biscuit.Fact in biscuit-go v2, recommends handling errors returned by EnforceExpiration and AddPolicy to prevent silent failures, and suggests adding a nil check for the token before accessing its expiry to avoid potential panics.

Comment on lines +78 to +85
for _, fact := range facts {
if len(fact.IDs) != 1 {
continue
}
date, ok := fact.IDs[0].(biscuit.Date)
if !ok {
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

In biscuit-go v2, biscuit.Fact has a named Predicate field of type Predicate. It does not embed Predicate anonymously, so accessing fact.IDs directly will result in a compilation error. You must access IDs via fact.Predicate.IDs.

Suggested change
for _, fact := range facts {
if len(fact.IDs) != 1 {
continue
}
date, ok := fact.IDs[0].(biscuit.Date)
if !ok {
continue
}
for _, fact := range facts {
if len(fact.Predicate.IDs) != 1 {
continue
}
date, ok := fact.Predicate.IDs[0].(biscuit.Date)
if !ok {
continue
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

biscuit.Fact embeds Predicate anonymously, so fact.IDs is a promoted field selector and compiles fine.

biscuit-go/v2@v2.2.0/types.go:116:

type Fact struct {
	Predicate
}

Field promotion applies. go build ./... and go vet ./... are green on this branch.

Applying this suggestion would actually break the build here, because staticcheck is an active linter and QF1008 rejects the longer form. That rule is what made me write it this way in the first place:

internal/controlplane/biscuit_ttl_test.go:64:23: QF1008: could remove embedded field "Predicate" from selector (staticcheck)

QF1008 only fires on embedded fields, so the diagnostic is itself proof that the field is embedded. No change.

Comment on lines +61 to +64
if len(facts) != 1 || len(facts[0].IDs) != 1 {
t.Fatalf("expected exactly one expiration fact, got %v", facts)
}
date, ok := facts[0].IDs[0].(biscuit.Date)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Similar to the issue in internal/identity/biscuit.go, biscuit.Fact has a named Predicate field. Accessing facts[0].IDs directly will cause a compilation error. Please access it via facts[0].Predicate.IDs.

Suggested change
if len(facts) != 1 || len(facts[0].IDs) != 1 {
t.Fatalf("expected exactly one expiration fact, got %v", facts)
}
date, ok := facts[0].IDs[0].(biscuit.Date)
if len(facts) != 1 || len(facts[0].Predicate.IDs) != 1 {
t.Fatalf("expected exactly one expiration fact, got %v", facts)
}
date, ok := facts[0].Predicate.IDs[0].(biscuit.Date)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

biscuit.Fact embeds Predicate anonymously, so fact.IDs is a promoted field selector and compiles fine.

biscuit-go/v2@v2.2.0/types.go:116:

type Fact struct {
	Predicate
}

Field promotion applies. go build ./... and go vet ./... are green on this branch.

Applying this suggestion would actually break the build here, because staticcheck is an active linter and QF1008 rejects the longer form. That rule is what made me write it this way in the first place:

internal/controlplane/biscuit_ttl_test.go:64:23: QF1008: could remove embedded field "Predicate" from selector (staticcheck)

QF1008 only fires on embedded fields, so the diagnostic is itself proof that the field is embedded. No change.

Comment on lines +55 to +63
func EnforceExpiration(authorizer biscuit.Authorizer) {
authorizer.AddFact(biscuit.Fact{
Predicate: biscuit.Predicate{
Name: api.FactTime,
IDs: []biscuit.Term{biscuit.Date(time.Now())},
},
})
authorizer.AddCheck(api.ControlPlaneStaticTimeCheck)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

In biscuit-go, AddFact and AddCheck return an error. Ignoring these errors can lead to silent failures where expiration checks are not properly enforced. Update EnforceExpiration to return an error so that callers can handle failures appropriately.

Suggested change
func EnforceExpiration(authorizer biscuit.Authorizer) {
authorizer.AddFact(biscuit.Fact{
Predicate: biscuit.Predicate{
Name: api.FactTime,
IDs: []biscuit.Term{biscuit.Date(time.Now())},
},
})
authorizer.AddCheck(api.ControlPlaneStaticTimeCheck)
}
func EnforceExpiration(authorizer biscuit.Authorizer) error {
if err := authorizer.AddFact(biscuit.Fact{
Predicate: biscuit.Predicate{
Name: api.FactTime,
IDs: []biscuit.Term{biscuit.Date(time.Now())},
},
}); err != nil {
return err
}
return authorizer.AddCheck(api.ControlPlaneStaticTimeCheck)
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authorizer.AddFact, AddCheck and AddPolicy all return nothing, so there is no error to propagate and EnforceExpiration has nothing to return.

biscuit-go/v2@v2.2.0/authorizer.go:22:

AddFact(fact Fact)
AddRule(rule Rule)
AddCheck(check Check)
AddPolicy(policy Policy)
Authorize() error
Query(rule Rule) (FactSet, error)

Authorize and Query are the two that return errors, and both are checked at every call site.

This looks like a mix-up with Builder.AddAuthorityFact, which does return an error and which we check everywhere. errcheck is also among the active linters, so an unchecked error could not have survived make lint.

These suggestions do not compile. No change.

Comment on lines +302 to 303
EnforceExpiration(authorizer)
authorizer.AddPolicy(api.AllowIfTruePolicy)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Handle the errors returned by EnforceExpiration and AddPolicy to ensure that failures during policy and expiration enforcement are not silently ignored.

		if err := EnforceExpiration(authorizer); err != nil {
			lastErr = err
			continue
		}
		if err := authorizer.AddPolicy(api.AllowIfTruePolicy); err != nil {
			lastErr = err
			continue
		}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authorizer.AddFact, AddCheck and AddPolicy all return nothing, so there is no error to propagate and EnforceExpiration has nothing to return.

biscuit-go/v2@v2.2.0/authorizer.go:22:

AddFact(fact Fact)
AddRule(rule Rule)
AddCheck(check Check)
AddPolicy(policy Policy)
Authorize() error
Query(rule Rule) (FactSet, error)

Authorize and Query are the two that return errors, and both are checked at every call site.

This looks like a mix-up with Builder.AddAuthorityFact, which does return an error and which we check everywhere. errcheck is also among the active linters, so an unchecked error could not have survived make lint.

These suggestions do not compile. No change.

Comment on lines +437 to +439
if enforceExpiry {
EnforceExpiration(auth)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Handle the error returned by EnforceExpiration to prevent proceeding with verification if the expiration check fails to be added.

Suggested change
if enforceExpiry {
EnforceExpiration(auth)
}
if enforceExpiry {
if err := EnforceExpiration(auth); err != nil {
lastErr = err
continue
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authorizer.AddFact, AddCheck and AddPolicy all return nothing, so there is no error to propagate and EnforceExpiration has nothing to return.

biscuit-go/v2@v2.2.0/authorizer.go:22:

AddFact(fact Fact)
AddRule(rule Rule)
AddCheck(check Check)
AddPolicy(policy Policy)
Authorize() error
Query(rule Rule) (FactSet, error)

Authorize and Query are the two that return errors, and both are checked at every call site.

This looks like a mix-up with Builder.AddAuthorityFact, which does return an error and which we check everywhere. errcheck is also among the active linters, so an unchecked error could not have survived make lint.

These suggestions do not compile. No change.

// Enforce client_peer_id matches connection_peer_id
authorizer.AddCheck(api.BaselineReplayCheck)

identity.EnforceExpiration(authorizer)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Handle the error returned by EnforceExpiration to ensure that any failure to inject the expiration check is caught and handled.

Suggested change
identity.EnforceExpiration(authorizer)
if err := identity.EnforceExpiration(authorizer); err != nil {
return fmt.Errorf("failed to enforce expiration: %w", err)
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authorizer.AddFact, AddCheck and AddPolicy all return nothing, so there is no error to propagate and EnforceExpiration has nothing to return.

biscuit-go/v2@v2.2.0/authorizer.go:22:

AddFact(fact Fact)
AddRule(rule Rule)
AddCheck(check Check)
AddPolicy(policy Policy)
Authorize() error
Query(rule Rule) (FactSet, error)

Authorize and Query are the two that return errors, and both are checked at every call site.

This looks like a mix-up with Builder.AddAuthorityFact, which does return an error and which we check everywhere. errcheck is also among the active linters, so an unchecked error could not have survived make lint.

These suggestions do not compile. No change.

Comment thread internal/node/node.go
Comment on lines +1478 to 1479
identity.EnforceExpiration(authorizer)
authorizer.AddPolicy(api.AllowIfTruePolicy)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Handle the errors returned by EnforceExpiration and AddPolicy to ensure that failures during policy and expiration enforcement are not silently ignored.

		if err := identity.EnforceExpiration(authorizer); err != nil {
			lastErr = err
			continue
		}
		if err := authorizer.AddPolicy(api.AllowIfTruePolicy); err != nil {
			lastErr = err
			continue
		}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authorizer.AddFact, AddCheck and AddPolicy all return nothing, so there is no error to propagate and EnforceExpiration has nothing to return.

biscuit-go/v2@v2.2.0/authorizer.go:22:

AddFact(fact Fact)
AddRule(rule Rule)
AddCheck(check Check)
AddPolicy(policy Policy)
Authorize() error
Query(rule Rule) (FactSet, error)

Authorize and Query are the two that return errors, and both are checked at every call site.

This looks like a mix-up with Builder.AddAuthorityFact, which does return an error and which we check everywhere. errcheck is also among the active linters, so an unchecked error could not have survived make lint.

These suggestions do not compile. No change.

Comment on lines +512 to +514
if token.Expiry.Before(biscuitExpiry) {
biscuitExpiry = token.Expiry
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential nil pointer dereference panics, verify that token is not nil before accessing token.Expiry.

Suggested change
if token.Expiry.Before(biscuitExpiry) {
biscuitExpiry = token.Expiry
}
if token != nil && token.Expiry.Before(biscuitExpiry) {
biscuitExpiry = token.Expiry
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

token cannot be nil at this point.

identity.VerifyJWT returns nil, nil, err on every failure path, and on success returns the non-nil *oidc.IDToken that verifier.Verify produced (internal/identity/oidc.go:94-99). HandleRegister returns immediately when it errors (server.go:440-444), so reaching this line means verification succeeded and token is non-nil.

The guard would be unreachable. Repo guidance is to validate at real boundaries rather than defend against states that cannot occur, and an unreachable nil check here would also suggest to a reader that a nil token is expected, which would be misleading. If VerifyJWT ever grew a claims, nil, nil path, the fix would belong there, where the invariant lives.

No change.

@aojea
aojea merged commit c3ad7bf into google:main Aug 26, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expired biscuits are accepted on the node dataplane: SamNode.Authorize does not inject the time fact / expiration check

1 participant