This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/master by this push:
     new 5edd1e9b7 Authorize the executed GraphQL operation, not the first 
definition (#874)
5edd1e9b7 is described below

commit 5edd1e9b78b6e078a737cfc50f15642eac42906e
Author: Serge Huber <[email protected]>
AuthorDate: Thu Sep 24 11:08:40 2026 +0200

    Authorize the executed GraphQL operation, not the first definition (#874)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../auth/GraphQLServletSecurityValidator.java      | 165 +++++++++++++++++----
 .../auth/GraphQLServletSecurityValidatorTest.java  |  49 ++++++
 .../itests/graphql/GraphQLServletSecurityIT.java   |  64 ++++++++
 .../security/bypass-ambiguous-multi-operation.json |   4 +
 .../graphql/security/bypass-extra-root-field.json  |   5 +
 .../graphql/security/bypass-fragment-spread.json   |   5 +
 .../security/bypass-introspection-named.json       |   5 +
 .../graphql/security/bypass-leading-fragment.json  |   5 +
 .../security/bypass-operation-name-smuggle.json    |   5 +
 9 files changed, 277 insertions(+), 30 deletions(-)

diff --git 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
index f9b0ea844..d6ef91881 100644
--- 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
+++ 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
@@ -41,7 +41,12 @@ import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Base64;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 import static graphql.language.OperationDefinition.Operation.*;
 import static org.osgi.service.http.HttpContext.AUTHENTICATION_TYPE;
@@ -86,7 +91,7 @@ public class GraphQLServletSecurityValidator {
     }
 
     public boolean validate(String query, String operationName, 
HttpServletRequest req, HttpServletResponse res) throws IOException {
-        if (isPublicOperation(query)) {
+        if (isPublicOperation(query, operationName)) {
             // For public operations, check API key
             String apiKey = req.getHeader("X-Unomi-Api-Key");
             if (apiKey != null) {
@@ -115,49 +120,149 @@ public class GraphQLServletSecurityValidator {
         }
     }
 
-    private boolean isPublicOperation(String query) {
-        if (query == null) {
+    /**
+     * Decides whether a GraphQL document may run against a public API key. 
The check authorizes the
+     * exact operation graphql-java will execute for the request (selected by 
{@code operationName}),
+     * evaluates every selected root field against the public allow-list, 
detects introspection
+     * structurally, and fails closed on anything it cannot prove public.
+     */
+    private boolean isPublicOperation(String query, String operationName) {
+        if (query == null || query.trim().isEmpty()) {
             return false;
         }
 
-        final Document queryDoc = parser.parseDocument(query);
-        if (queryDoc.getDefinitions().isEmpty()) {
+        final Document queryDoc;
+        try {
+            queryDoc = parser.parseDocument(query);
+        } catch (RuntimeException e) {
+            // Unparseable input is never treated as public; execution will 
surface the syntax error.
+            LOG.debug("Failed to parse GraphQL document; refusing public 
classification", e);
             return false;
         }
-        final Definition<?> def = queryDoc.getDefinitions().get(0);
-        if (def instanceof OperationDefinition) {
-            OperationDefinition opDef = (OperationDefinition) def;
-            if (SUBSCRIPTION.equals(opDef.getOperation())) {
-                // subscriptions are not public
-                return false;
-            } else if ("IntrospectionQuery".equals(opDef.getName())) {
-                // allow introspection query
-                return true;
-            }
 
-            List<Node> children = opDef.getSelectionSet().getChildren();
-            final Field cdp = (Field) children.stream().filter((node) -> {
-                return (node instanceof Field) && "cdp".equals(((Field) 
node).getName());
-            }).findFirst().orElse(null);
-            if (cdp == null) {
-                // allow not a cdp namespace
-                return true;
+        // Separate the document into its operations and a fragment lookup 
table.
+        final Map<String, FragmentDefinition> fragments = new HashMap<>();
+        final List<OperationDefinition> operations = new ArrayList<>();
+        for (Definition<?> def : queryDoc.getDefinitions()) {
+            if (def instanceof OperationDefinition) {
+                operations.add((OperationDefinition) def);
+            } else if (def instanceof FragmentDefinition) {
+                final FragmentDefinition fragment = (FragmentDefinition) def;
+                fragments.put(fragment.getName(), fragment);
             }
+        }
+        if (operations.isEmpty()) {
+            // No executable operation (e.g. a fragment-only document): fail 
closed.
+            return false;
+        }
 
-            final List<String> allowedNodeNames = new ArrayList<>();
-            if (QUERY.equals(opDef.getOperation())) {
-                allowedNodeNames.add("getProfile");
-            } else if (MUTATION.equals(opDef.getOperation())) {
-                allowedNodeNames.add("processEvents");
+        // Authorize exactly the operation graphql-java will execute, not 
simply the first definition.
+        final OperationDefinition operation = 
resolveExecutedOperation(operations, operationName);
+        if (operation == null) {
+            // Ambiguous, duplicated or unknown operation name: fail closed.
+            return false;
+        }
+        return isOperationPublic(operation, fragments);
+    }
+
+    /**
+     * Resolves the operation graphql-java will execute: with an explicit 
{@code operationName} exactly
+     * one operation must match; without a name the document must contain 
exactly one operation (per the
+     * GraphQL spec). Anything else is invalid input and is refused.
+     */
+    private OperationDefinition 
resolveExecutedOperation(List<OperationDefinition> operations, String 
operationName) {
+        if (operationName != null && !operationName.trim().isEmpty()) {
+            OperationDefinition match = null;
+            for (OperationDefinition operation : operations) {
+                if (operationName.equals(operation.getName())) {
+                    if (match != null) {
+                        return null; // duplicate operation name: invalid 
document
+                    }
+                    match = operation;
+                }
             }
+            return match;
+        }
+        return operations.size() == 1 ? operations.get(0) : null;
+    }
 
-            return 
cdp.getSelectionSet().getChildren().stream().allMatch((node) -> {
-                return (node instanceof Field) && 
allowedNodeNames.contains(((Field) node).getName());
-            });
+    private boolean isOperationPublic(OperationDefinition operation, 
Map<String, FragmentDefinition> fragments) {
+        // Subscriptions are never public.
+        if (SUBSCRIPTION.equals(operation.getOperation())) {
+            return false;
+        }
+
+        final List<Field> topLevelFields = 
collectFields(operation.getSelectionSet(), fragments, new HashSet<>());
+        if (topLevelFields.isEmpty()) {
+            return false;
+        }
+
+        // Introspection is detected structurally, never by the operation's 
name: a query whose top-level
+        // selections are all meta fields (__schema / __type / __typename) is 
public.
+        if (QUERY.equals(operation.getOperation())
+                && topLevelFields.stream().allMatch(field -> field.getName() 
!= null && field.getName().startsWith("__"))) {
+            return true;
+        }
+
+        // Every public operation lives entirely under a single "cdp" root; 
touching anything else is refused.
+        final List<Field> cdpFields = topLevelFields.stream()
+                .filter(field -> "cdp".equals(field.getName()))
+                .collect(Collectors.toList());
+        if (cdpFields.isEmpty() || cdpFields.size() != topLevelFields.size()) {
+            return false;
+        }
+
+        final List<String> allowedNodeNames = new ArrayList<>();
+        if (QUERY.equals(operation.getOperation())) {
+            allowedNodeNames.add("getProfile");
+        } else if (MUTATION.equals(operation.getOperation())) {
+            allowedNodeNames.add("processEvents");
+        } else {
+            return false;
+        }
+
+        for (Field cdp : cdpFields) {
+            final List<Field> cdpChildren = 
collectFields(cdp.getSelectionSet(), fragments, new HashSet<>());
+            if (cdpChildren.isEmpty()) {
+                // An empty cdp selection is not an affirmatively public 
request.
+                return false;
+            }
+            for (Field child : cdpChildren) {
+                if (!allowedNodeNames.contains(child.getName())) {
+                    return false;
+                }
+            }
         }
         return true;
     }
 
+    /**
+     * Flattens a selection set into concrete fields, resolving inline 
fragments and fragment spreads
+     * (guarding against fragment cycles) so nothing can be hidden from the 
allow-list inside a fragment.
+     */
+    private List<Field> collectFields(SelectionSet selectionSet, Map<String, 
FragmentDefinition> fragments, Set<String> visitedFragments) {
+        final List<Field> fields = new ArrayList<>();
+        if (selectionSet == null) {
+            return fields;
+        }
+        for (Selection selection : selectionSet.getSelections()) {
+            if (selection instanceof Field) {
+                fields.add((Field) selection);
+            } else if (selection instanceof InlineFragment) {
+                fields.addAll(collectFields(((InlineFragment) 
selection).getSelectionSet(), fragments, visitedFragments));
+            } else if (selection instanceof FragmentSpread) {
+                final String name = ((FragmentSpread) selection).getName();
+                if (visitedFragments.add(name)) {
+                    final FragmentDefinition fragment = fragments.get(name);
+                    if (fragment != null) {
+                        
fields.addAll(collectFields(fragment.getSelectionSet(), fragments, 
visitedFragments));
+                    }
+                }
+            }
+        }
+        return fields;
+    }
+
     /**
      * Authenticates a Basic credential that did not arrive as a request 
header — used by the WebSocket
      * {@code connection_init} handshake, which is the only way a browser 
client can present credentials
diff --git 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
index c8a150e02..0f842f220 100644
--- 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
+++ 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
@@ -223,6 +223,55 @@ class GraphQLServletSecurityValidatorTest {
         verify(securityService, never()).setCurrentSubject(any());
     }
 
+    @Test
+    void validate_operationAuthorizationBypasses_areRejectedForPublicApiKey() 
throws IOException {
+        // Even holding a valid public API key, none of these documents may 
run: each is a way the
+        // public-operation gate could be tricked into classifying a 
privileged or ambiguous document as
+        // public. They must all fail closed - no public branch, and with no 
Authorization header, 401.
+        
lenient().when(request.getHeader("X-Unomi-Api-Key")).thenReturn("public-api-key");
+
+        // A privileged mutation smuggled as a second operation and selected 
by operationName.
+        assertNotPublic("query GetProfile { cdp { 
getProfile(profileID:{id:\"x\"}) { id } } } "
+                + "mutation Pwn { cdp { 
deleteAllPersonalData(profileID:{id:\"v\"}) } }", "Pwn");
+        // A privileged query whose operation is merely NAMED 
IntrospectionQuery.
+        assertNotPublic("query IntrospectionQuery { cdp { 
findProfiles(first:1000) { edges { node { properties } } } } }", null);
+        // A leading fragment definition in front of a privileged operation.
+        assertNotPublic("fragment f on Query { __typename } query Q { cdp { 
deleteProfile(profileID:{id:\"v\"}) } }", "Q");
+        // A privileged field hidden inside a fragment spread.
+        assertNotPublic("query Q { cdp { ...priv } } fragment priv on 
CDP_Query { findProfiles(first:10) { edges { node { id } } } }", "Q");
+        // An extra non-cdp root field alongside an allowed one.
+        assertNotPublic("query Q { cdp { getProfile(profileID:{id:\"x\"}) { id 
} } segments { edges { node { id } } } }", "Q");
+        // Multiple operations with no operationName: the executed operation 
is ambiguous.
+        assertNotPublic("query A { cdp { getProfile(profileID:{id:\"x\"}) { id 
} } } "
+                + "query B { cdp { findProfiles(first:1) { edges { node { id } 
} } } }", null);
+        // A syntactically invalid document.
+        assertNotPublic("query { cdp { getProfile ", null);
+
+        verify(securityService, never()).setCurrentSubject(any());
+    }
+
+    @Test
+    void validate_legitimatePublicOperation_isAuthenticatedWithPublicApiKey() 
throws IOException {
+        Tenant tenant = new Tenant();
+        tenant.setItemId("pub-tenant");
+        
when(request.getHeader("X-Unomi-Api-Key")).thenReturn("public-api-key");
+        when(tenantService.getTenantByApiKey("public-api-key", 
ApiKey.ApiKeyType.PUBLIC)).thenReturn(tenant);
+        when(securityService.createSubject("pub-tenant", 
false)).thenReturn(new Subject());
+        ExecutionContext context = new ExecutionContext("pub-tenant", null, 
null);
+        
when(executionContextManager.createContext("pub-tenant")).thenReturn(context);
+
+        boolean authenticated = validator.validate(
+                "query { cdp { getProfile(profileID:{id:\"x\"}) { id } } }", 
null, request, response);
+
+        assertTrue(authenticated);
+        verify(executionContextManager).setCurrentContext(context);
+    }
+
+    private void assertNotPublic(String query, String operationName) throws 
IOException {
+        assertFalse(validator.validate(query, operationName, request, 
response),
+                "Expected document to be denied for a public API key: " + 
query);
+    }
+
     /**
      * An unset {@code org.apache.unomi.security.root.password} resolves to 
the empty string, which
      * {@code PropertiesLoginModule} accepts as the shipped administrator's 
password (UNOMI-974).
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLServletSecurityIT.java
 
b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLServletSecurityIT.java
index 4288ceaaf..6cb2e44aa 100644
--- 
a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLServletSecurityIT.java
+++ 
b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLServletSecurityIT.java
@@ -84,4 +84,68 @@ public class GraphQLServletSecurityIT extends BaseGraphQLIT {
             Assert.assertEquals(401, response.getStatusLine().getStatusCode());
         }
     }
+
+    // 
---------------------------------------------------------------------------------------------
+    // Operation-authorization bypasses.
+    //
+    // Each payload below is a way a caller holding only the page-embedded 
public API key could try to
+    // get a privileged operation executed. The public allow-list is 
getProfile and processEvents only,
+    // so every one of these must be refused. They are written as end-to-end 
requests deliberately: the
+    // decision has to hold against the real parser and the real servlet, not 
just in a unit test.
+    // 
---------------------------------------------------------------------------------------------
+
+    /** An operation merely NAMED IntrospectionQuery must not be treated as 
introspection. */
+    @Test
+    public void 
testPublicKeyCannotRunPrivilegedOperationNamedIntrospectionQuery() throws 
Exception {
+        
assertRefusedForPublicKey("graphql/security/bypass-introspection-named.json");
+    }
+
+    /** A privileged operation selected by operationName, hidden behind a 
benign first operation. */
+    @Test
+    public void 
testPublicKeyCannotSmugglePrivilegedOperationViaOperationName() throws 
Exception {
+        
assertRefusedForPublicKey("graphql/security/bypass-operation-name-smuggle.json");
+    }
+
+    /** A leading fragment definition must not make the document look 
non-executable. */
+    @Test
+    public void testPublicKeyCannotBypassWithLeadingFragment() throws 
Exception {
+        
assertRefusedForPublicKey("graphql/security/bypass-leading-fragment.json");
+    }
+
+    /** A privileged field hidden inside a fragment spread must still be seen. 
*/
+    @Test
+    public void testPublicKeyCannotHidePrivilegedFieldInFragmentSpread() 
throws Exception {
+        
assertRefusedForPublicKey("graphql/security/bypass-fragment-spread.json");
+    }
+
+    /** Several operations and no operationName: which one executes is 
ambiguous, so it must be refused. */
+    @Test
+    public void testPublicKeyCannotUseAmbiguousMultiOperationDocument() throws 
Exception {
+        
assertRefusedForPublicKey("graphql/security/bypass-ambiguous-multi-operation.json");
+    }
+
+    /** An allowed field does not license a second root field alongside it. */
+    @Test
+    public void testPublicKeyCannotAddExtraRootFieldBesideAllowedOne() throws 
Exception {
+        
assertRefusedForPublicKey("graphql/security/bypass-extra-root-field.json");
+    }
+
+    /**
+     * A public-key caller must not get privileged data out of this document. 
Refusal is either a 401 or
+     * a 200 carrying no data — both are acceptable outcomes, what matters is 
that nothing privileged is
+     * returned. Asserting only on the status code would let a 200-with-data 
regression pass unnoticed.
+     */
+    private void assertRefusedForPublicKey(final String resource) throws 
Exception {
+        try (CloseableHttpResponse response = postWithAuthType(resource, 
AuthType.PUBLIC_KEY)) {
+            final int status = response.getStatusLine().getStatusCode();
+            if (status == 401) {
+                return;
+            }
+            Assert.assertEquals("Expected the request to be refused (401) or 
to return no privileged data",
+                    200, status);
+            final ResponseContext context = 
ResponseContext.parse(response.getEntity());
+            Assert.assertNull("A public API key must not be able to read 
profiles through " + resource,
+                    context.getValue("data.cdp.findProfiles"));
+        }
+    }
 }
diff --git 
a/itests/src/test/resources/graphql/security/bypass-ambiguous-multi-operation.json
 
b/itests/src/test/resources/graphql/security/bypass-ambiguous-multi-operation.json
new file mode 100644
index 000000000..6270eaece
--- /dev/null
+++ 
b/itests/src/test/resources/graphql/security/bypass-ambiguous-multi-operation.json
@@ -0,0 +1,4 @@
+{
+  "variables": {},
+  "query": "query Public {\n  cdp {\n    getProfile(profileID: {id: 
\"nobody\"}) {\n      cdp_profileIDs {\n        id\n      }\n    }\n  
}\n}\n\nquery Privileged {\n  cdp {\n    findProfiles(first: 100) {\n      
edges {\n        node {\n          cdp_profileIDs {\n            id\n          
}\n        }\n      }\n    }\n  }\n}\n"
+}
diff --git 
a/itests/src/test/resources/graphql/security/bypass-extra-root-field.json 
b/itests/src/test/resources/graphql/security/bypass-extra-root-field.json
new file mode 100644
index 000000000..b35f870db
--- /dev/null
+++ b/itests/src/test/resources/graphql/security/bypass-extra-root-field.json
@@ -0,0 +1,5 @@
+{
+  "operationName": "Mixed",
+  "variables": {},
+  "query": "query Mixed {\n  cdp {\n    getProfile(profileID: {id: 
\"nobody\"}) {\n      cdp_profileIDs {\n        id\n      }\n    }\n  }\n  
__schema {\n    types {\n      name\n    }\n  }\n}\n"
+}
diff --git 
a/itests/src/test/resources/graphql/security/bypass-fragment-spread.json 
b/itests/src/test/resources/graphql/security/bypass-fragment-spread.json
new file mode 100644
index 000000000..65297540b
--- /dev/null
+++ b/itests/src/test/resources/graphql/security/bypass-fragment-spread.json
@@ -0,0 +1,5 @@
+{
+  "operationName": "Privileged",
+  "variables": {},
+  "query": "query Privileged {\n  cdp {\n    ...privileged\n  }\n}\n\nfragment 
privileged on CDP_Query {\n  findProfiles(first: 100) {\n    edges {\n      
node {\n        cdp_profileIDs {\n          id\n        }\n      }\n    }\n  
}\n}\n"
+}
diff --git 
a/itests/src/test/resources/graphql/security/bypass-introspection-named.json 
b/itests/src/test/resources/graphql/security/bypass-introspection-named.json
new file mode 100644
index 000000000..09a8b50a6
--- /dev/null
+++ b/itests/src/test/resources/graphql/security/bypass-introspection-named.json
@@ -0,0 +1,5 @@
+{
+  "operationName": "IntrospectionQuery",
+  "variables": {},
+  "query": "query IntrospectionQuery {\n  cdp {\n    findProfiles(first: 100) 
{\n      edges {\n        node {\n          cdp_profileIDs {\n            id\n  
        }\n        }\n      }\n    }\n  }\n}\n"
+}
diff --git 
a/itests/src/test/resources/graphql/security/bypass-leading-fragment.json 
b/itests/src/test/resources/graphql/security/bypass-leading-fragment.json
new file mode 100644
index 000000000..91518038a
--- /dev/null
+++ b/itests/src/test/resources/graphql/security/bypass-leading-fragment.json
@@ -0,0 +1,5 @@
+{
+  "operationName": "Privileged",
+  "variables": {},
+  "query": "fragment ignored on CDP_Query {\n  __typename\n}\n\nquery 
Privileged {\n  cdp {\n    findProfiles(first: 100) {\n      edges {\n        
node {\n          cdp_profileIDs {\n            id\n          }\n        }\n    
  }\n    }\n  }\n}\n"
+}
diff --git 
a/itests/src/test/resources/graphql/security/bypass-operation-name-smuggle.json 
b/itests/src/test/resources/graphql/security/bypass-operation-name-smuggle.json
new file mode 100644
index 000000000..a3824c8ba
--- /dev/null
+++ 
b/itests/src/test/resources/graphql/security/bypass-operation-name-smuggle.json
@@ -0,0 +1,5 @@
+{
+  "operationName": "Privileged",
+  "variables": {},
+  "query": "query Public {\n  cdp {\n    getProfile(profileID: {id: 
\"nobody\"}) {\n      cdp_profileIDs {\n        id\n      }\n    }\n  
}\n}\n\nquery Privileged {\n  cdp {\n    findProfiles(first: 100) {\n      
edges {\n        node {\n          cdp_profileIDs {\n            id\n          
}\n        }\n      }\n    }\n  }\n}\n"
+}

Reply via email to