From cba01dbdbc6b53cae4c38a35effb51128044e2ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 20:06:08 +0000 Subject: [PATCH] fix(api-gateway): enforce meta scope on /v1/graphql-to-json `initApp()` registered `/v1/graphql-to-json` under the "graphql scope" comment block but never asserted any scope, so a token that an operator had explicitly denied access could still reach it. Every sibling endpoint asserts its scope; this one was missed. The correct scope is `meta`, not `graphql`. The handler only reads the data model metadata and translates a GraphQL query string into a Cube JSON query - it executes nothing and returns no data, so the surface it exposes is the one `/v1/meta` guards. Requiring `graphql` would also have been wrong in practice: a user holding `meta` + `data` but not `graphql` has a legitimate reason to convert a GraphQL query into JSON they then run via `/v1/load`. Both directions are covered by tests. While here, removes a dead block from the handler: let schema = compilerApi.getGraphQLSchema(); if (!schema) { schema = makeSchema(metaConfig); compilerApi.setGraphQLSchema(schema); } `schema` is never read - `getJsonQueryFromGraphQLQuery()` takes `metaConfig`, not a schema. Its only effect was writing the compiler API's shared GraphQL schema cache, and because it built the schema without `skipVisibilityPatch` it could prime that cache with a narrower schema that `/graphql` then served to other users in the same compiler context. Also mounts `jsonParser` on the route. It was the only POST endpoint without one, silently relying on the host app to mount a body parser app-wide; without one the handler threw on destructuring `req.body`. `userAsyncHandler` is needed so the scope rejection reaches the error middleware instead of leaving the request hanging. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EsEm9cas3XTPrjcQeRQBdP --- .../core-data-apis/rest-api/index.mdx | 2 +- packages/cubejs-api-gateway/src/gateway.ts | 53 ++++++++--------- .../test/permissions.test.ts | 57 +++++++++++++++++++ 3 files changed, 85 insertions(+), 27 deletions(-) diff --git a/docs-mintlify/reference/core-data-apis/rest-api/index.mdx b/docs-mintlify/reference/core-data-apis/rest-api/index.mdx index 70557d0c97f3e..af121c1331a41 100644 --- a/docs-mintlify/reference/core-data-apis/rest-api/index.mdx +++ b/docs-mintlify/reference/core-data-apis/rest-api/index.mdx @@ -185,7 +185,7 @@ accessible for everyone. | API scope | REST (JSON) API endpoints | Accessible by default? | | --- | --- | --- | -| `meta` | [`/v1/meta`][ref-ref-meta], [Metadata API][ref-ref-metadata] | ✅ Yes | +| `meta` | [`/v1/meta`][ref-ref-meta], [Metadata API][ref-ref-metadata], `/v1/graphql-to-json` | ✅ Yes | | `data` | [`/v1/load`][ref-ref-load], [`/v1/cubesql`][ref-ref-cubesql] | ✅ Yes | | `graphql` | `/graphql` | ✅ Yes | | `sql` | [`/v1/sql`][ref-ref-sql] | ✅ Yes | diff --git a/packages/cubejs-api-gateway/src/gateway.ts b/packages/cubejs-api-gateway/src/gateway.ts index cdac72992b27c..7753373b5900e 100644 --- a/packages/cubejs-api-gateway/src/gateway.ts +++ b/packages/cubejs-api-gateway/src/gateway.ts @@ -336,32 +336,6 @@ class ApiGateway { * graphql scope * *************************************************************** */ - app.post(`${this.basePath}/v1/graphql-to-json`, userMiddlewares, async (req: any, res) => { - const { query, variables } = req.body; - const compilerApi = await this.getCompilerApi(req.context); - - const metaConfig = await compilerApi.metaConfig(req.context, { - requestId: req.context.requestId, - }); - - let schema = compilerApi.getGraphQLSchema(); - if (!schema) { - schema = makeSchema(metaConfig); - compilerApi.setGraphQLSchema(schema); - } - - try { - const jsonQuery = getJsonQueryFromGraphQLQuery(query, metaConfig, variables); - res.json({ jsonQuery }); - } catch (e: any) { - const stack = getEnv('devMode') ? e.stack : undefined; - this.logger('GraphQL to JSON error', { - error: (stack || e).toString(), - }); - res.json({ jsonQuery: null }); - } - }); - app.use( `${this.basePath}/graphql`, userMiddlewares, @@ -541,6 +515,33 @@ class ApiGateway { }) ); + // Named for GraphQL but guarded by `meta`: it only reads the data model + // metadata to translate a query string, and executes nothing. + app.post(`${this.basePath}/v1/graphql-to-json`, jsonParser, userMiddlewares, userAsyncHandler(async (req: any, res) => { + await this.assertApiScope( + 'meta', + req?.context?.securityContext + ); + + const { query, variables } = req.body; + const compilerApi = await this.getCompilerApi(req.context); + + const metaConfig = await compilerApi.metaConfig(req.context, { + requestId: req.context.requestId, + }); + + try { + const jsonQuery = getJsonQueryFromGraphQLQuery(query, metaConfig, variables); + res.json({ jsonQuery }); + } catch (e: any) { + const stack = getEnv('devMode') ? e.stack : undefined; + this.logger('GraphQL to JSON error', { + error: (stack || e).toString(), + }); + res.json({ jsonQuery: null }); + } + })); + app.post( `${this.basePath}/v1/cubesql`, userMiddlewares, diff --git a/packages/cubejs-api-gateway/test/permissions.test.ts b/packages/cubejs-api-gateway/test/permissions.test.ts index b1a850f15437c..07e978068ce77 100644 --- a/packages/cubejs-api-gateway/test/permissions.test.ts +++ b/packages/cubejs-api-gateway/test/permissions.test.ts @@ -48,6 +48,15 @@ describe('Gateway Api Scopes', () => { expect(res.body && res.body.error) .toStrictEqual('API scope is missing: graphql'); + res = await request(app) + .post('/cubejs-api/v1/graphql-to-json') + .set('Content-type', 'application/json') + .set('Authorization', AUTH_TOKEN) + .send({ query: 'query { cube { Foo { bar } } }' }) + .expect(403); + expect(res.body && res.body.error) + .toStrictEqual('API scope is missing: meta'); + res = await request(app) .get('/cubejs-api/v1/meta') .set('Authorization', AUTH_TOKEN) @@ -114,6 +123,44 @@ describe('Gateway Api Scopes', () => { apiGateway.release(); }); + // `/v1/graphql-to-json` only reads the data model metadata, so it is guarded + // by the `meta` scope - not `graphql`, which gates the GraphQL API itself. + test('GraphQL to JSON declined without meta scope', async () => { + const { app, apiGateway } = createApiGateway({ + contextToApiScopes: async () => ['graphql', 'data', 'jobs'], + }); + + const res = await request(app) + .post('/cubejs-api/v1/graphql-to-json') + .set('Content-type', 'application/json') + .set('Authorization', AUTH_TOKEN) + .send({ query: 'query { cube { Foo { bar } } }' }) + .expect(403); + + expect(res.body && res.body.error) + .toStrictEqual('API scope is missing: meta'); + + apiGateway.release(); + }); + + test('GraphQL to JSON allowed with meta scope but no graphql scope', async () => { + const { app, apiGateway } = createApiGateway({ + contextToApiScopes: async () => ['meta', 'data', 'jobs'], + }); + + const res = await request(app) + .post('/cubejs-api/v1/graphql-to-json') + .set('Content-type', 'application/json') + .set('Authorization', AUTH_TOKEN) + .send({ query: 'query { cube { Foo { bar } } }' }) + .expect(200); + + expect(res.body && res.body.jsonQuery) + .toStrictEqual({ measures: ['Foo.bar'] }); + + apiGateway.release(); + }); + test('Meta declined', async () => { const { app, apiGateway } = createApiGateway({ contextToApiScopes: async () => ['graphql', 'data', 'jobs'], @@ -135,6 +182,16 @@ describe('Gateway Api Scopes', () => { expect(res2.body && res2.body.error) .toStrictEqual('API scope is missing: meta'); + const res3 = await request(app) + .post('/cubejs-api/v1/graphql-to-json') + .set('Content-type', 'application/json') + .set('Authorization', AUTH_TOKEN) + .send({ query: 'query { cube { Foo { bar } } }' }) + .expect(403); + + expect(res3.body && res3.body.error) + .toStrictEqual('API scope is missing: meta'); + apiGateway.release(); });