Skip to content

fix(api-gateway): enforce meta scope on /v1/graphql-to-json - #11939

Merged
paveltiunov merged 1 commit into
masterfrom
pavel-claude/friendly-brown-0dflnd
Sep 22, 2026
Merged

paveltiunov merged 1 commit into
masterfrom
pavel-claude/friendly-brown-0dflnd

Conversation

@paveltiunov

@paveltiunov paveltiunov commented Sep 19, 2026

Copy link
Copy Markdown
Member

Check List

  • Tests have been run in packages where changes have been made if available
  • Linter has been run for changed code
  • Tests for the changes have been added if not covered yet
  • Docs have been added / updated if required

Description of Changes Made

initApp() registered /v1/graphql-to-json under the graphql scope comment block but never asserted any scope, so a token an operator had explicitly denied could still reach it. Every sibling endpoint asserts its scope; this one was missed.

Reproduced against the gateway wired the way @cubejs-backend/server wires it (NODE_ENV=production, app-wide bodyParser.json(), CUBEJS_DEFAULT_API_SCOPES=data), same token to both endpoints:

POST /cubejs-api/graphql            -> 403 {"error":"API scope is missing: graphql"}
POST /cubejs-api/v1/graphql-to-json -> 200 {"jsonQuery":{"measures":["Foo.bar"]}}

Why meta and 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 pinned by tests — denied without meta, still allowed with meta but no graphql.

The route is registered in the existing meta scope section next to /v1/meta.

Dead code removed

let schema = compilerApi.getGraphQLSchema();
if (!schema) {
  schema = makeSchema(metaConfig);
  compilerApi.setGraphQLSchema(schema);
}

schema is never read — getJsonQueryFromGraphQLQuery(query, metaConfig, variables) takes metaConfig, not a schema. The block's 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. Verified before/after: setGraphQLSchema calls from this path drop to 0, and /graphql now serves the full unfiltered schema regardless of call ordering.

This is also what made the endpoint look like a GraphQL endpoint; with it gone, meta is unambiguous.

Supporting changes

  • Wraps the handler in userAsyncHandler so the scope rejection reaches the error middleware. Without it the rejected promise is unhandled and the request hangs rather than returning 403 — this showed up as a 5s test timeout.
  • Mounts jsonParser on the route. It previously relied on the host app mounting a body parser app-wide, and threw a TypeError on destructuring req.body without one. Note this does not close the class: /v1/cubesql, /v1/pre-aggregations/can-use and /v1/pre-aggregations/jobs read req.body with no parser mounted either. Left for a follow-up rather than widened into an authorization fix.
  • Documents the endpoint in the API scopes table. The table is still incomplete — it omits /v1/pre-aggregations/can-use under meta and /v1/dry-run, /v1/convert-query, /v1/subscribe under data — so this adds the endpoint at issue without claiming to fix the table.

Impact

Operators who restrict meta (Cube's docs use "restrict access to the /v1/meta endpoint to service accounts only" as the worked example) could not enforce it — denied users still reached data-model structure through this endpoint. Requires a valid token, and default scopes are ['graphql', 'meta', 'data', 'sql'], so stock installs were not affected by the authorization bypass. The schema cache write did apply to default configurations.

Behaviour change worth a release note: a deployment granting graphql + data but not meta currently gets working GraphQL→JSON conversion in Playground's Query Builder v2 and will now get a 403. That is the intended effect of the fix.

Testing

  • packages/cubejs-api-gateway: 288/288 passing, including 4 new/extended assertions in test/permissions.test.ts.
  • Confirmed the new tests fail against the unpatched handler (the request reaches the handler body instead of 403) and pass after.
  • Scope matrix re-verified after relocating the route: ["data"] → 403, ["graphql","data"] → 403, ["meta"] → 200, ["meta","graphql"] → 200.
  • oxlint clean on changed files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EsEm9cas3XTPrjcQeRQBdP

@paveltiunov
paveltiunov requested review from a team and keydunov as code owners September 19, 2026 20:07
@mintlify

mintlify Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
cubed3 🟢 Ready View Preview Sep 19, 2026, 8:15 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@github-actions github-actions Bot added docs Issues that require a documentation improvement javascript Pull requests that update Javascript code labels Sep 19, 2026
@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @paveltiunov's task in 2m 39s —— View job


Approve with nits — 0 high, 0 medium, 3 low

Full review
  • Read the diff
  • Read gateway.ts in full context (route table, scope sections, middleware wiring)
  • Verify the removed schema-priming block is genuinely dead and makeSchema still used
  • Verify scope choice & middleware ordering
  • Review tests and mocks
  • Check prior review threads
  • Post findings

Verdict

The core fix is correct. /v1/graphql-to-json did reach the handler with no scope assertion, and the three supporting changes are each load-bearing rather than incidental:

  • userAsyncHandler is genuinely required — assertApiScope throws CubejsHandlerError(403) from an async body, and without the wrapper the rejection never reaches the error middleware.
  • The removed cache-priming block is dead as claimed. getGraphQLSchema/setGraphQLSchema are now read only by the /graphql route (packages/cubejs-api-gateway/src/gateway.ts:386), which builds its schema with skipVisibilityPatch: true; the deleted block built one without it, so it could prime the shared cache with a visibility-filtered schema that /graphql then served to everyone in the same compiler context. makeSchema is still imported and used at packages/cubejs-api-gateway/src/gateway.ts:392 — the import is not orphaned.
  • meta over graphql holds up. The handler reads metaConfig and translates a query string; it executes nothing. Both directions are pinned by tests, which is the right call given the choice is arguable.

Mock-level check of the new assertion: compilerApi.metaConfig in packages/cubejs-api-gateway/test/mocks.ts:83 exposes Foo.bar as a visible measure, so { measures: ['Foo.bar'] } is the right expectation for the allowed-with-meta test.

Findings

# Severity Location Issue
1 Low packages/cubejs-api-gateway/src/gateway.ts:337 Second meta scope banner duplicates the existing one at :527; route belongs in that block, which also retires the over-long comment
2 Low packages/cubejs-api-gateway/src/gateway.ts:350 req.body ?? {} is unreachable once jsonParser is mounted
3 Low PR description "only POST endpoint without one" is inaccurate

Findings 1 and 2 are posted inline.

Finding 3 — the jsonParser claim. Three more POST routes read req.body with no parser mounted:

  • packages/cubejs-api-gateway/src/gateway.ts:552/v1/cubesql, const { query } = req.body
  • packages/cubejs-api-gateway/src/gateway.ts:593/v1/pre-aggregations/can-use, const { transformedQuery, references } = req.body
  • packages/cubejs-api-gateway/src/gateway.ts:617/v1/pre-aggregations/jobs

They share exactly the latent failure this PR fixes on graphql-to-json — a host app that doesn't mount bodyParser.json() app-wide gets a TypeError on destructuring undefined. Out of scope for an authorization fix, and I would not hold the PR for it, but the description should not claim the class is now closed. A follow-up mounting jsonParser on those three would be small and self-contained.

Notes, not findings

Behaviour change for a narrow configuration. A deployment granting graphql + data but not meta currently gets working GraphQL→JSON conversion in Playground (packages/cubejs-playground/src/QueryBuilderV2/utils/graphql-converters.ts:24) and will now get a 403. That is the intended effect of the fix and such a deployment could barely render Query Builder v2 anyway, but it is a visible change for someone who deliberately restricted meta. Worth a line in the release notes rather than a code change.

Docs table completeness. Adding /v1/graphql-to-json to the meta row is right, but the table still omits /v1/pre-aggregations/can-use (also meta, packages/cubejs-api-gateway/src/gateway.ts:597) and /v1/dry-run, /v1/convert-query, /v1/subscribe under data. This weakens the description's "previously unlisted, which is plausibly how the mismatch survived" reasoning — the table has never been exhaustive. Not a blocker.

Visibility asymmetry. graphql-to-json reads metaConfig without skipVisibilityPatch while /graphql builds its schema with it, so a query valid against the served GraphQL schema can fail to convert. The direction is conservative (the translator sees less, never more), so this is an observation rather than a defect.

Tests

New coverage is well targeted — denied without meta, allowed with meta but no graphql, plus the empty-default-scopes case. Pinning the allowed direction is what stops a later "it says graphql in the name" revert.

I could not execute the suite: this is a fresh checkout with no node_modules at the repo root or in packages/cubejs-api-gateway, and a full Lerna/Yarn install was out of budget for this run. The 288/288 figure in the description is unverified by me; CI on this PR is the authority. Everything above is from reading the source, not from a run.

Review threads

The listing returned 0 threads of my own (empty, single page) — no prior threads. 0 resolved, 0 re-affirmed.

· branch [`pavel-claude/friendly-brown-0dflnd`](https://github.com/cube-js/cube/tree/pavel-claude/friendly-brown-0dflnd)

Comment thread packages/cubejs-api-gateway/src/gateway.ts Outdated
Comment thread packages/cubejs-api-gateway/src/gateway.ts Outdated
// the data model metadata and executes nothing, so it belongs to the `meta`
// scope rather than `graphql` - the surface it exposes is the same one
// `/v1/meta` guards.
app.post(`${this.basePath}/v1/graphql-to-json`, jsonParser, userMiddlewares, userAsyncHandler(async (req: any, res) => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fixing this in this PR, and flagging that the CodeQL check is consequently red — reasoning below so a maintainer can dismiss the alert if they agree.

The alert fires because of this PR's fix, not because of a new exposure. js/missing-rate-limiting flags route handlers that perform authorization; this handler previously performed none, which is the bug being fixed. Adding assertApiScope is what made CodeQL classify it as an authorization route, so the alert is a direct artifact of adding the security check. The endpoint is no more reachable than before — strictly less so.

It is also not specific to this route. There are 13 inline assertApiScope call sites in gateway.ts, including /graphql (:343) with the identical shape, plus /v1/cubesql (:557) and /v1/pre-aggregations/can-use (:590) — all on master, none rate-limited. CodeQL only surfaces alerts in code a PR changed, which is why this one appeared and the others didn't.

And there is nothing to be consistent with: grep for rate-limit/rateLimit across cubejs-api-gateway, cubejs-server and cubejs-server-core returns nothing, and express-rate-limit is not a dependency of any of them. Rate limiting is handled ahead of the gateway rather than inside it.

So the available fix would be to introduce a rate-limiting dependency and apply it to exactly one endpoint out of ~20 — inconsistent with every sibling, a new runtime behaviour (throttling a legitimate Playground call path) and well outside an authorization fix. Doing it properly means a gateway-wide policy with configuration, which is its own change.

I have no means to dismiss the alert myself. Happy to open a follow-up issue for gateway-wide rate limiting if that's wanted; otherwise this needs a maintainer to dismiss as "won't fix" so the check can go green.


Generated by Claude Code

@paveltiunov
paveltiunov force-pushed the pavel-claude/friendly-brown-0dflnd branch from 574fab0 to f21bbe8 Compare September 19, 2026 20:12
@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Claude Code is working…

I'll analyze this and get back to you.

View job run

`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsEm9cas3XTPrjcQeRQBdP
@paveltiunov
paveltiunov force-pushed the pavel-claude/friendly-brown-0dflnd branch from f21bbe8 to cba01db Compare September 19, 2026 20:14
@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @paveltiunov's task in 2m 14s —— View job


Approve — no defects found. 0 high, 0 medium, 1 low (pre-existing, out of scope).

Full review
  • Read changed files in full
  • Check scope/auth logic and route ordering
  • Check tests
  • Check docs
  • Reconcile prior review threads
  • Post findings

What I verified

Scope choice. meta is right. The handler at packages/cubejs-api-gateway/src/gateway.ts:520-543 reads compilerApi.metaConfig() and calls getJsonQueryFromGraphQLQuery — no adapter, no query execution, no result rows. Its exposure is exactly the data-model structure /v1/meta guards. Requiring graphql instead would have blocked the legitimate meta+data flow (convert, then run via /v1/load), and both directions are pinned by the new tests.

Route placement after the relocation. app.use(${basePath}/graphql, …) at gateway.ts:339 prefix-matches /cubejs-api/graphql; /cubejs-api/v1/graphql-to-json does not start with that string, so it is not swallowed. No route registered between :339 and the new registration at :520 (/v1/load, /v1/subscribe, /v1/sql, /v1/dry-run, /v1/convert-query, DELETE /v1/running-query/:requestId, GET /v1/meta) matches POST /v1/graphql-to-json. jsonParser is defined at :390, ahead of the use at :520 — the hoist really is unnecessary now.

Dead-code removal is a real fix, not just cleanup. The removed block built the schema without skipVisibilityPatch and wrote it into the shared cache via setGraphQLSchema. /graphql at gateway.ts:350-360 deliberately caches the unfiltered schema ("RBAC enforcement happens at query execution time"), so the old path could poison that cache with a narrower schema for every other caller sharing the compiler context. Removing it restores the documented invariant. makeSchema is still imported and used at :358, so the import is not orphaned.

userAsyncHandler wrap. Necessary, not cosmetic — without it the assertApiScope rejection is an unhandled promise and the response never lands. Matches every sibling route.

Tests. test/mocks.ts:83-130 returns a real Foo.bar measure, so the 200-case assertion { measures: ['Foo.bar'] } exercises the translation rather than just the status code. The matrix covers denied-without-meta, allowed-with-meta-but-no-graphql, empty CUBEJS_DEFAULT_API_SCOPES, and the existing "Meta declined" case. That is the right shape.

Consumer check. The only in-repo caller is packages/cubejs-playground/src/QueryBuilderV2/utils/graphql-converters.ts:24, which sends Content-Type: application/json and needs meta anyway to populate the builder. No break.

Docs. The meta row in docs-mintlify/reference/core-data-apis/rest-api/index.mdx:188 is the correct row, and plain backticks (rather than a [ref-…] link) are right since no reference page exists for this endpoint.

Low — pre-existing, out of scope

The PR description says /v1/graphql-to-json was "the only POST endpoint without" a body parser. POST /v1/cubesql at gateway.ts:545-549 also destructures req.body with no jsonParser mounted, so it carries the same silent dependency on a host-mounted app-wide parser. Not introduced here and not this PR's job — flagging it as a follow-up, since the reasoning that found this bug applies there verbatim. Fix this →

Comments

The two-line comment at gateway.ts:518-519 is within the 3-line guideline and earns its place: an endpoint named graphql-to-json filed under the meta scope banner reads as a mistake, and that exact mis-filing is the bug being fixed. Keep it.

Not actionable

The CodeQL "Missing rate limiting" alert applies to every authenticated route in this file, not to anything this diff introduced. Awaiting maintainer dismissal as noted on that thread.

Review threads

The listing returned 1 unresolved thread, 0 of them mine — it was opened by github-advanced-security, so nothing to resolve or re-affirm. Resolved 0, re-affirmed 0.

Not run

jest and tsc were both blocked by command approval in this environment, so I did not independently reproduce the 288/288 result reported in the PR description. Review is from reading the code and tests.

· `pavel-claude/friendly-brown-0dflnd`

@codecov

codecov Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.32%. Comparing base (42bfe75) to head (cba01db).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
packages/cubejs-api-gateway/src/gateway.ts 66.66% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #11939      +/-   ##
==========================================
+ Coverage   61.23%   61.32%   +0.08%     
==========================================
  Files         247      247              
  Lines       19824    19821       -3     
  Branches     4043     4042       -1     
==========================================
+ Hits        12140    12156      +16     
+ Misses       7112     7092      -20     
- Partials      572      573       +1     
Flag Coverage Δ
cube-backend 61.32% <66.66%> (+0.08%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@paveltiunov
paveltiunov merged commit f566e35 into master Sep 22, 2026
76 of 77 checks passed
@paveltiunov
paveltiunov deleted the pavel-claude/friendly-brown-0dflnd branch September 22, 2026 19:35

This branch was successfully deployed

1 active deployment
staging - docs-mintlify cba01dbd Deployed Sep 19, 2026 by mintlify[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Issues that require a documentation improvement javascript Pull requests that update Javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants