CVE-2025-31481

The Relay Race to Nowhere: Bypassing Auth in API Platform's GraphQL Node Interface

Alon Barad
Alon Barad
Software Engineer

Jan 13, 2026·5 min read

Executive Summary (TL;DR)

API Platform's implementation of the GraphQL Relay `node` field failed to load resource-specific security metadata. By querying a sensitive resource via `node(id: "/iri")`, attackers could bypass `security` attributes (like `ROLE_ADMIN`) and read data they shouldn't see. The fix involves forcing a reverse lookup of the operation metadata based on the requested IRI.

A logic error in API Platform's GraphQL Relay implementation allows attackers to bypass security rules by querying resources via the global `node` interface, effectively ignoring configured access controls.

The Hook: The Skeleton Key in the Graph

In the world of GraphQL, the Relay specification is like the high-society club of API design. It mandates strict standards for global object identification, pagination, and consistency. One of its crown jewels is the node interface. It’s a magical, polymorphic entry point that says, "Give me an ID, and I will give you the object, no matter what type it is."

From a developer's perspective, this is pure convenience. You don't need to know if ID 123 is a User, a Post, or a NuclearLaunchCode. You just query node(id: "...") and the server figures it out. It's the ultimate skeleton key for your data graph.

But here is the problem with skeleton keys: if you forget to check who is holding them, they open doors for the wrong people. In CVE-2025-31481, API Platform—one of the most popular PHP frameworks for building API-driven projects—made a classic mistake. They implemented the mechanics of the key perfectly but forgot to check the lock's security instructions when that specific key was used.

The Flaw: Identity Crisis in the Resolver

To understand this bug, you have to understand how API Platform secures its endpoints. Typically, you define a resource (let's say SecuredDummy) and attach security attributes to its operations. You might say: "To read this, the user must have ROLE_ADMIN."

When you hit the standard endpoint (e.g., query { securedDummy(id: "...") }), API Platform looks up the metadata for that operation, sees the security rule, and enforces it. Simple.

However, when you use the global node query, the request hits a specialized ResolverFactory. The resolver needs an Operation object to know what rules to apply. In vulnerable versions, if the resolver couldn't immediately determine the operation context (which happens with the generic node field), it shrugged its shoulders and instantiated a generic, blank Query object.

[!WARNING] The Fatal Flaw: A blank Query object has no security rules attached to it. It is a ghost operation.

Because the security system works by checking constraints on the current operation, it looked at this ghost operation, saw zero constraints, and effectively said, "Go right ahead." The data loader then fetched the sensitive resource using the ID provided, completely bypassing the Access Control List (ACL) meant for that specific resource.

The Smoking Gun: Defaulting to Insecurity

Let's look at the code. The vulnerability lived in src/GraphQl/Resolver/Factory/ResolverFactory.php. This class is responsible for creating the logic that fetches data.

Here is the logic pre-patch. Notice how it handles the absence of an operation:

// Vulnerable Code (simplified)
private function resolve($source, $args, $context, ...)
{
    // ... initialization logic ...
 
    // If no specific operation is defined (common in 'node' queries),
    // just make up a blank one.
    $operation ??= new Query();
 
    // Proceed to execute the query with the blank operation...
    // Security listeners check $operation, find nothing, and pass.
}

The fix was to stop being lazy. Instead of defaulting to new Query(), the patch forces the system to look up the actual operation associated with the requested ID (IRI). They introduced a RuntimeOperationMetadataFactory to reverse-engineer the request:

// Patched Code (Commit 60747cc8c2fb855798c923b5537888f8d0969568)
if (!$operation) {
    if (!isset($args['id'])) {
        throw new NotFoundHttpException('No node found.');
    }
    // CRITICAL FIX: Determine the real operation from the ID
    $operation = $this->operationMetadataFactory->create($args['id']);
}

By adding this lookup, the resolver now finds the original SecuredDummy configuration, sees the is_granted('ROLE_ADMIN') rule, and correctly denies access.

The Exploit: Walking Through the Side Door

Exploiting this is trivially easy if you know the IRI (Internationalized Resource Identifier) of the target resource. In API Platform, IRIs usually look like /resource_name/id.

Let's assume there is a resource called Salaries which is strictly restricted to HR administrators.

Attempt 1: The Front Door (Blocked) Standard GraphQL query:

query {
  salary(id: "/salaries/1") {
    amount
    employee
  }
}

Result: 403 Forbidden (The salary query operation correctly enforces ROLE_HR).

Attempt 2: The Side Door (Vulnerable) Using the Relay node interface:

query {
  node(id: "/salaries/1") {
    ... on Salary {
      amount
      employee
    }
  }
}

Result: 200 OK. The node resolver uses the blank Query object. The security check passes (because there are no rules to check), and the underlying data provider fetches the row from the database. The attacker gets the JSON payload containing the sensitive salary data.

The Impact: Read-Only, but Deadly

This is primarily a Confidentiality breach. The CVSS score is 7.5 (High), which is appropriate. It allows unauthenticated or low-privilege users to read data they are explicitly forbidden from seeing.

While this doesn't directly allow for Write/Delete operations (Mutations in GraphQL are handled differently and usually don't go through the node query resolver in this specific way), the ability to exfiltrate an entire database is often arguably worse than defacing it.

Consider an application hosting medical records or financial data. An attacker can write a script to iterate through IDs (e.g., /patients/1, /patients/2) using the node query and dump the entire dataset without ever triggering an authorization error.

The Fix: Reverse Engineering the Request

The mitigation is straightforward: Update. The patch introduces a clever mechanism that uses the Symfony Router to parse the incoming IRI inside the GraphQL resolver.

How it works now:

  1. Request comes in for node(id: "/salaries/1").
  2. The RuntimeOperationMetadataFactory takes /salaries/1.
  3. It matches this string against the registered routes to find the resource class (App\Entity\Salary).
  4. It retrieves the GraphQL operations defined for Salary.
  5. It selects the get or query operation metadata, which contains security: "is_granted('ROLE_HR')".
  6. The security listener enforces this rule, blocking the request.

Developers using API Platform versions < 3.4.17 or >= 4.0.0 < 4.0.22 are vulnerable and must upgrade immediately.

Fix Analysis (1)

Technical Appendix

CVSS Score
7.5/ 10
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
EPSS Probability
0.08%
Top 100% most exploited

Affected Systems

API Platform Core < 3.4.17API Platform Core >= 4.0.0, < 4.0.22

Affected Versions Detail

Product
Affected Versions
Fixed Version
api-platform/core
api-platform
< 3.4.173.4.17
api-platform/core
api-platform
>= 4.0.0, < 4.0.224.0.22
AttributeDetail
CWE IDCWE-863 (Incorrect Authorization)
CVSS v3.17.5 (High)
Attack VectorNetwork (GraphQL API)
ImpactConfidentiality Loss (Data Exfiltration)
Exploit StatusTrivial / PoC Available
KEV StatusNot Listed
CWE-863
Incorrect Authorization

The software does not perform or incorrectly performs an authorization check when an actor attempts to access a resource or perform an action.

Vulnerability Timeline

Vulnerability Disclosed
2025-04-03
Patch Released (3.4.17, 4.0.22)
2025-04-03

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.