CVE-2025-46331 (Go)

OpenFGA's CachedCheckResolver can incorrectly treat cycle-dependent Check results as reusable, context-independent authorization decisions. When an authorization model and relationship tuples create recursive or cyclic relationships, a Check or ListObjects evaluation may reach the same tuple subproblem under different path-sensitive evaluation contexts, including different visited-path/cycle state or recursion depth metadata. A result produced while a cycle was detected may be valid only for the parent resolution context that produced it, whether the result is allowed or denied, and must not be reused to decide a later evaluation context where the same tuple subproblem has different cycle semantics.

An attacker who can influence authorization models, relationship tuples, or requests that trigger cyclic relationship evaluation can cause a stale cycle-dependent Check result to be reused across Check/ListObjects resolution paths. This can incorrectly grant access that should be denied, resulting in an authorization bypass, and can also produce other incorrect authorization decisions.

Weakness:
CWE-284: name: Improper Access Control
description: The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor.

Repository: https://github.com/openfga/openfga

Vulnerable code:
file_path: internal/graph/cached_resolver.go
snippet: func (c *CachedCheckResolver) ResolveCheck(
	ctx context.Context,
	req *ResolveCheckRequest,
) (*ResolveCheckResponse, error) {
	span := trace.SpanFromContext(ctx)

	cacheKey := BuildCacheKey(*req)

	tryCache := req.Consistency != openfgav1.ConsistencyPreference_HIGHER_CONSISTENCY

	if tryCache {
		checkCacheTotalCounter.Inc()
		if cachedResp := c.cache.Get(cacheKey); cachedResp != nil {
			res := cachedResp.(*CheckResponseCacheEntry)
			isValid := res.LastModified.After(req.LastCacheInvalidationTime)
			c.logger.Debug("CachedCheckResolver found cache key",
				zap.String("store_id", req.GetStoreID()),
				zap.String("authorization_model_id", req.GetAuthorizationModelID()),
				zap.String("tuple_key", req.GetTupleKey().String()),
				zap.Bool("isValid", isValid))

			span.SetAttributes(attribute.Bool("cached", isValid))
			if isValid {
				checkCacheHitCounter.Inc()
				// return a copy to avoid races across goroutines
				return res.CheckResponse.clone(), nil
			}

			// we tried the cache and hit an invalid entry
			checkCacheInvalidHit.Inc()
		} else {
			c.logger.Debug("CachedCheckResolver not found cache key",
				zap.String("store_id", req.GetStoreID()),
				zap.String("authorization_model_id", req.GetAuthorizationModelID()),
				zap.String("tuple_key", req.GetTupleKey().String()))
		}
	}

	// not in cache, or consistency options experimental flag is set, and consistency param set to HIGHER_CONSISTENCY
	resp, err := c.delegate.ResolveCheck(ctx, req)
	if err != nil {
		telemetry.TraceError(span, err)
		return nil, err
	}

	clonedResp := resp.clone()

	c.cache.Set(cacheKey, &CheckResponseCacheEntry{LastModified: time.Now(), CheckResponse: clonedResp}, c.cacheTTL)
	return resp, nil
}

Fixed code:
file_path: internal/graph/cached_resolver.go
snippet: func (c *CachedCheckResolver) ResolveCheck(
	ctx context.Context,
	req *ResolveCheckRequest,
) (*ResolveCheckResponse, error) {
	span := trace.SpanFromContext(ctx)

	cacheKey := BuildCacheKey(*req)

	tryCache := req.Consistency != openfgav1.ConsistencyPreference_HIGHER_CONSISTENCY

	if tryCache {
		checkCacheTotalCounter.Inc()
		if cachedResp := c.cache.Get(cacheKey); cachedResp != nil {
			res := cachedResp.(*CheckResponseCacheEntry)
			isValid := res.LastModified.After(req.LastCacheInvalidationTime)
			c.logger.Debug("CachedCheckResolver found cache key",
				zap.String("store_id", req.GetStoreID()),
				zap.String("authorization_model_id", req.GetAuthorizationModelID()),
				zap.String("tuple_key", req.GetTupleKey().String()),
				zap.Bool("isValid", isValid))

			span.SetAttributes(attribute.Bool("cached", isValid))
			if isValid {
				checkCacheHitCounter.Inc()
				// return a copy to avoid races across goroutines
				return res.CheckResponse.clone(), nil
			}

			// we tried the cache and hit an invalid entry
			checkCacheInvalidHit.Inc()
		} else {
			c.logger.Debug("CachedCheckResolver not found cache key",
				zap.String("store_id", req.GetStoreID()),
				zap.String("authorization_model_id", req.GetAuthorizationModelID()),
				zap.String("tuple_key", req.GetTupleKey().String()))
		}
	}

	// not in cache, or consistency options experimental flag is set, and consistency param set to HIGHER_CONSISTENCY
	resp, err := c.delegate.ResolveCheck(ctx, req)
	if err != nil {
		telemetry.TraceError(span, err)
		return nil, err
	}

	// when the response indicates cycle detected. The result is indeterminate because the
	// parent of the cycle could have resolved to true. Thus, we don't save the result and let
	// the parent handle it.
	if resp.GetCycleDetected() {
		span.SetAttributes(attribute.Bool("cycle_detected", true))
		c.logger.Debug("CachedCheckResolver not saving to cache due to cycle",
			zap.String("store_id", req.GetStoreID()),
			zap.String("authorization_model_id", req.GetAuthorizationModelID()),
			zap.String("tuple_key", req.GetTupleKey().String()))
		return resp, nil
	}

	clonedResp := resp.clone()

	c.cache.Set(cacheKey, &CheckResponseCacheEntry{LastModified: time.Now(), CheckResponse: clonedResp}, c.cacheTTL)
	return resp, nil
}