Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions internal/serviceoffercontroller/agent_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ func (c *Controller) resolveAgentOffer(ctx context.Context, offer *monetizeapi.S
if ref.Name == "" || ref.Namespace == "" {
return false, fmt.Errorf("type=agent offer %s/%s missing spec.agent.ref", offer.Namespace, offer.Name)
}
if ref.Namespace != offer.Namespace {
// Confused-deputy guard: the verifier route source injects the
// hermes-api-server API_SERVER_KEY from ref.Namespace into the
// outbound Authorization header. Allowing a cross-namespace ref
// would let any principal with serviceoffers write in namespace A
// expose Hermes /api in namespace B as an x402-gated route under
// attacker-controlled path and payTo, granting paying buyers
// authenticated proxy access to the victim agent.
return false, fmt.Errorf("type=agent offer %s/%s: spec.agent.ref.namespace %q must equal offer namespace", offer.Namespace, offer.Name, ref.Namespace)
}

raw, err := c.agents.Namespace(ref.Namespace).Get(ctx, ref.Name, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
Expand Down
49 changes: 49 additions & 0 deletions internal/serviceoffercontroller/agent_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func TestResolveAgentOffer_PopulatesFromReadyAgent(t *testing.T) {
c := newResolverTestController(t, agent)

offer := &monetizeapi.ServiceOffer{
ObjectMeta: metav1.ObjectMeta{Namespace: "agent-quant"},
Spec: monetizeapi.ServiceOfferSpec{
Type: "agent",
Agent: monetizeapi.ServiceOfferAgent{
Expand Down Expand Up @@ -83,6 +84,7 @@ func TestResolveAgentOffer_NotReadyAgentClearsResolution(t *testing.T) {
c := newResolverTestController(t, agent)

offer := &monetizeapi.ServiceOffer{
ObjectMeta: metav1.ObjectMeta{Namespace: "agent-quant"},
Spec: monetizeapi.ServiceOfferSpec{
Type: "agent",
Agent: monetizeapi.ServiceOfferAgent{
Expand Down Expand Up @@ -113,6 +115,7 @@ func TestResolveAgentOffer_MissingAgentReturnsNotReady(t *testing.T) {
c := newResolverTestController(t)

offer := &monetizeapi.ServiceOffer{
ObjectMeta: metav1.ObjectMeta{Namespace: "agent-missing"},
Spec: monetizeapi.ServiceOfferSpec{
Type: "agent",
Agent: monetizeapi.ServiceOfferAgent{
Expand Down Expand Up @@ -147,6 +150,52 @@ func TestResolveAgentOffer_RejectsMissingRef(t *testing.T) {
}
}

// TestResolveAgentOffer_RejectsCrossNamespaceRef guards the confused-deputy
// invariant: an offer in namespace A must not be allowed to reference an agent
// in namespace B, because the verifier route source injects ref.Namespace's
// hermes-api-server API_SERVER_KEY as the upstream Authorization. Allowing a
// cross-namespace ref would let any principal with serviceoffers write expose
// another tenant's Hermes /api as an x402-gated route under attacker-controlled
// path + payTo.
func TestResolveAgentOffer_RejectsCrossNamespaceRef(t *testing.T) {
agent := &monetizeapi.Agent{
TypeMeta: metav1.TypeMeta{APIVersion: "obol.org/v1alpha1", Kind: "Agent"},
ObjectMeta: metav1.ObjectMeta{Name: "victim", Namespace: "agent-victim"},
Status: monetizeapi.AgentStatus{
Phase: monetizeapi.AgentPhaseReady,
Endpoint: "http://hermes.agent-victim.svc.cluster.local:8642",
},
}
c := newResolverTestController(t, agent)

offer := &monetizeapi.ServiceOffer{
ObjectMeta: metav1.ObjectMeta{Name: "spoof", Namespace: "attacker-ns"},
Spec: monetizeapi.ServiceOfferSpec{
Type: "agent",
Agent: monetizeapi.ServiceOfferAgent{
Ref: monetizeapi.ServiceOfferAgentRef{Name: "victim", Namespace: "agent-victim"},
},
},
}
status := monetizeapi.ServiceOfferStatus{
AgentResolution: &monetizeapi.ServiceOfferAgentResolution{Model: "stale"},
}

ok, err := c.resolveAgentOffer(context.Background(), offer, &status)
if err == nil {
t.Fatal("expected error for cross-namespace spec.agent.ref")
}
if ok {
t.Fatal("expected ok=false for cross-namespace ref")
}
if status.AgentResolution == nil || status.AgentResolution.Model != "stale" {
// Guard fires before touching status: the caller is responsible for
// the failure-mode condition update, and we should not silently wipe
// a prior AgentResolution.
t.Errorf("guard must reject without mutating status.AgentResolution; got %+v", status.AgentResolution)
}
}

func newResolverTestController(t *testing.T, agents ...*monetizeapi.Agent) *Controller {
t.Helper()
objs := make([]runtime.Object, 0, len(agents))
Expand Down
Loading