Skip to content

Backend: gate /join-requests endpoints on caller=curator server-side (defense-in-depth) #757

Description

@Jurij89

Summary

The daemon's join-request endpoints (GET /api/context-graph/{id}/join-requests, POST .../approve-join, POST .../reject-join) are not curator-gated server-side. Any caller with a valid bearer token can read pending-moderation metadata for any context graph they know the ID of, and the approve/reject handlers record the caller's address as audit metadata without verifying it matches cg.curator.

The frontend currently enforces the curator-only convention client-side (in PendingJoinRequestsSection). This is sufficient for V10's clean-slate UX (every CG payload includes cg.curator, so the frontend always has the data it needs to gate correctly), but it's a defense-in-depth gap — anyone bypassing the UI (curl, postman, a second frontend, a script) can read or act on join-request data they shouldn't have access to.

Surfaced during PR #745 (S2 Context Graph Overview finalize), Codex review sweep 6 — see #745 (comment) and the thread defending PR #745's Bug Q at #745 (comment).

Vulnerable code paths

GET — packages/cli/src/daemon/routes/context-graph.ts:898-909

// GET /api/context-graph/{id}/join-requests — list pending join requests (curator view)
const joinRequestsMatch = path.match(/^\/api\/context-graph\/([^/]+)\/join-requests$/);
if (req.method === "GET" && joinRequestsMatch) {
  const contextGraphId = decodeURIComponent(joinRequestsMatch[1]);
  try {
    const requests = await agent.listPendingJoinRequests(contextGraphId);
    return jsonResponse(res, 200, { contextGraphId, requests });
  } catch (err: unknown) {
    const msg = err instanceof Error ? err.message : String(err);
    return jsonResponse(res, 400, { error: msg });
  }
}

The handler comment says (curator view) but the handler delegates straight through to agent.listPendingJoinRequests without any caller-identity check.

Agent layer — packages/agent/src/dkg-agent.ts:13126-13152

async listPendingJoinRequests(contextGraphId: string): Promise<> {
  const cgMetaGraph = contextGraphMetaGraphUri(contextGraphId);
  const DKG = 'https://dkg.network/ontology#';
  const result = await this.store.query(
    `SELECT ?addr ?name ?sig ?ts ?status WHERE {
      GRAPH <${cgMetaGraph}> {
        ?req a <${DKG}JoinRequest> ;
             <${DKG}agentAddress> ?addr ;
             <${DKG}signature> ?sig ;
             <${DKG}requestTimestamp> ?ts ;
             <${DKG}requestStatus> ?status .
        OPTIONAL { ?req <https://schema.org/name> ?name }
      }
    }`,
  );
  // ... no curator-identity check ...
  return result.bindings.map(...).filter((r) => r.status === 'pending');
}

Plain SPARQL read against the CG meta-graph. No authorisation check.

POST handlers — packages/cli/src/daemon/routes/context-graph.ts ~911-925 (approve) and the matching reject handler

The callerAgentAddress parameter is captured for audit metadata but is not verified against the CG's curator before the underlying agent method is invoked.

What the bug means in practice

Today, with any valid bearer token:

  • curl /api/context-graph/{id}/join-requests returns the full list of pending requesters' addresses, signatures, timestamps, and any display names — for any CG ID the caller can construct, regardless of whether that caller has any role on the CG.
  • curl -X POST .../approve-join (with a forged or arbitrary callerAgentAddress) currently flips a pending request to approved without the daemon verifying the caller is the curator.

The frontend's UI-side gate is the only thing currently preventing this in normal user flows. Any second-party tool, scripted client, or future UI surface that forgets the gate will bypass it.

Proposed fix

In the daemon route handler (packages/cli/src/daemon/routes/context-graph.ts), before delegating to the agent layer for any of the join-request endpoints:

  1. Resolve the caller's identity from the request's bearer token (assuming the auth middleware already exposes this on req.auth or similar — use whatever the existing pattern is).
  2. Load the CG's curator field (or the canonical curator identifier the agent layer can return).
  3. Compare via canonicalAgentDid (the same canonicaliser the frontend uses, exposed from @origintrail/dkg-agent or its equivalent).
  4. If the caller is not the curator, return 403 Forbidden with a body like { "error": "Curator role required" }.
  5. Otherwise, proceed to the existing handler logic.

Pseudo-code:

const joinRequestsMatch = path.match(/^\/api\/context-graph\/([^/]+)\/join-requests$/);
if (req.method === "GET" && joinRequestsMatch) {
  const contextGraphId = decodeURIComponent(joinRequestsMatch[1]);
  const cg = await agent.getContextGraphMetadata(contextGraphId);
  const callerId = canonicalAgentDid(req.auth.agentDid ?? req.auth.agentAddress);
  if (!cg?.curator || canonicalAgentDid(cg.curator) !== callerId) {
    return jsonResponse(res, 403, { error: "Curator role required" });
  }
  // existing handler...
}

Mirror the check on the approve/reject POST handlers.

Test plan

  • Unit / handler:

    • GET /api/context-graph/{id}/join-requests as the curator → 200 + request list.
    • GET .../join-requests as a non-curator agent → 403.
    • GET .../join-requests with no bearer / unauthenticated → 401 or whatever the existing auth-middleware shape returns.
    • POST .../approve-join as the curator → 200, request flips to approved.
    • POST .../approve-join as a non-curator → 403, request stays pending, audit metadata not written.
    • Same matrix for POST .../reject-join.
  • Integration:

    • Spin a daemon with two distinct agents A and B. Create a CG curated by A. From B's daemon credentials, attempt all three endpoints — all should 403.
    • From A's credentials, all three should succeed.
  • Regression:

    • Existing daemon tests for approve/reject should pass with the curator credential — verify the pre-existing tests aren't running with a non-curator identity (they may have been doing so accidentally; if they were, those tests need updating too).

Frontend follow-up after the backend fix lands

Once the route gates server-side, the frontend can re-introduce the optimistic-fetch UX that was implemented and then reverted in PR #745 (the Bug L cycle — see #745 (comment) for the history). With server-side gating in place, the frontend can:

  • Attempt listJoinRequests() in the 'unknown' curator-status state (during /api/agent/identity loading or error).
  • On 403 response, hide the section cleanly.
  • On 200, show the requests + approve/reject controls.

This collapses the current 3-state UX (curator / not-curator / unknown) for the join-requests surface into a 2-state UX driven by the server's authoritative answer, eliminating the "Verifying access…" panel that real curators currently see in some transient identity-loading edge cases.

Priority

Medium — defense-in-depth, not UX-blocking. The frontend's client-side gate covers the normal V10 user flow correctly; the V10 invariant (every CG payload includes cg.curator) means real curators are never stuck in client-side 'unknown' state in practice. The exposure is to non-UI clients and any future surface that doesn't re-implement the gate. Worth scheduling alongside the next round of API hardening, but not blocking PR #745 or other Context Graph workstream items.

Acceptance

  • All three join-request endpoints (GET, approve POST, reject POST) return 403 for non-curators.
  • Auth middleware integration documented in the handler comments.
  • Unit and integration tests as above.
  • Frontend follow-up tracked separately (or noted in the PR description) so the Overview UX collapses to the 2-state shape after this lands.

Metadata

Metadata

Assignees

No one assigned

    Labels

    pre-mainnetMust land before mainnet launchpriority:highMust-fix: protocol correctness, security, economics, or headline flow broken

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions