This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch graphql-authorize-selected-operation in repository https://gitbox.apache.org/repos/asf/unomi.git
commit 4a41fd898752ad31ab70226c0899d3be4d187518 Author: Serge Huber <[email protected]> AuthorDate: Fri Aug 28 07:33:00 2026 +0200 Cover public-operation authorization with integration tests Adds a case for each way a caller holding only the public API key could try to have a privileged operation executed: naming an operation IntrospectionQuery, selecting a second operation by operationName, leading with a fragment definition, hiding a field in a fragment spread, leaving the executed operation ambiguous, and adding a root field beside an allowed one. These run end to end deliberately. The decision depends on how the real parser shapes a document and on what the servlet then executes, so a unit test over the classifier alone would not settle it. Each asserts that no privileged data comes back rather than only checking a status code, since a regression that returned data with a 200 would otherwise pass. Co-Authored-By: Claude Opus 4.8 <[email protected]> --- .../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 ++ 7 files changed, 93 insertions(+) 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" +}
