{"vulnerability": "CVE-2026-1322", "sightings": [{"uuid": "d9d914ce-a81b-40d4-95dc-bfc07a4974ac", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2026-1322", "type": "seen", "source": "https://gist.github.com/MarisollieNULL/b7806963ce981e6e0f6a6d25ed3ecd8a", "content": "# Find the Higher Layer Before You Chase the Asymmetry\n\n_Two GitLab sibling-of-CVE hunts that survived hypothesis-formation and both died at the same shape: a higher layer enforcing uniformly._\n\n## The setup\n\nCVE-2026-1322 was a GitLab GraphQL scope-bypass where `:read_api`-scoped tokens could execute a subset of mutations that should have required `:api`. The fix narrowed the per-mutation scope check. After it landed, I went sibling-hunting on the same surface across two sessions: S21 on a pre-patch shape, S22 on a post-patch shape. Both hypotheses fired cleanly at /scope-time, both survived their pre-mortem-DA paragraphs, both died at lab-probe. Both deaths landed on the same structural shape, which is what this writeup is about.\n\n## Hypothesis one, phantom plumbing\n\nCH-1' in S21. GitLab forwards a `scope_validator` object from its REST API endpoints into the issuable service layer, and a few GraphQL mutations do the same. `lib/api/issues.rb:325` threads `scope_validator: context[:scope_validator]` into service params, and three GraphQL mutations (`app/graphql/mutations/work_items/create.rb:104-105`, `work_items/update.rb`, `notes/create/base.rb`) thread it analogously. Twenty-six other mutations on the same surface don't.\n\nThe asymmetry looks exploitable on first read. If the threaded validator is what enforces OAuth scope on quick-action sub-operations inside a mutation, then the twenty-six mutations that skip the threading would let a `:read_api` token execute scoped sub-operations they shouldn't. I built CH-1' on exactly that reading.\n\nThen I grepped for the validator's consumer. `app/services/issuable_base_service.rb:195-198` packages the validator into params and passes them into `QuickActions::InterpretService`. The InterpretService accepts the validator argument. So far the threading is real.\n\nBut `lib/gitlab/quick_actions/`, the DSL code that interprets every `/label` and `/assign` directive, has zero references to `scope_validator`. The service layer threads the object into the DSL layer, and the DSL layer never reads it. `app/services/quick_actions/interpret_service.rb:15` defines `QuickActionsNotAllowedError = Class.new(StandardError)`, and the error is never raised anywhere in the codebase.\n\nThe plumbing exists. Nothing reads it. The twenty-six-mutation asymmetry produces no exploitable surface because the scope decision happens upstream at `BaseMutation.authorized?` calling `scopes_ok?` before any mutation body runs. That's phantom plumbing: a security check prototyped or rolled back, the scaffolding kept, the actual check moved elsewhere.\n\n## Hypothesis two, master gate filter\n\nCH-NOVEL-3 in S22. CVE-2026-1322's fix introduced a new scope, `:ai_workflows`, with the intent that AI-related mutations would be reachable by tokens carrying it. Four mutations override `authorization_scopes` to add the new scope to the default set:\n\n```ruby\n# app/graphql/mutations/work_items/create.rb:19-21\ndef self.authorization_scopes\n  super + [:ai_workflows]\nend\n```\n\nSame shape at `notes/create/note.rb`, `notes/update/note.rb`, and `work_items/update.rb`. And `:ai_workflows` is registered in `Gitlab::Auth.optional_scopes`, so Doorkeeper's OAuth-application registration accepts it as a valid scope. The UI scope-picker hides it; the backend service path accepts it via direct POST. I confirmed the asymmetry end-to-end on a lab GitLab Omnibus instance. A non-admin `victim` user registered an OAuth application requesting `scopes='ai_workflows'`, the `Applications::CreateService` accepted it, Doorkeeper issued a token.\n\nThe token works at the REST surface. `GET /api/v4/user` with the `Bearer ai_workflows_token` returns 200 and the user JSON. Per-mutation override is real, per-surface acceptance is real, the token is real.\n\nGraphQL says 401 \"Invalid token\" to every payload. Even a bare `{ currentUser { id } }`.\n\nRoot cause is at the master endpoint scope gate.\n\n```ruby\n# lib/gitlab/auth/request_authenticator.rb:93-95\ndef graphql_authorization_scopes\n  [:api, :read_api]\nend\n```\n\nThe master gate filters every token by its scope set before the per-mutation authorization check ever runs. `:ai_workflows`-only tokens get rejected at the GraphQL endpoint entry. The four-mutation `super + [:ai_workflows]` override is dead code from an external-attacker perspective. It exists for internal pipelines that already hold `:api` or `:read_api` alongside `:ai_workflows`, where the additive scope grants extra surface but isn't the only credential.\n\nBoth pre-patch (`:read_api` token) and post-patch (`:ai_workflows` token) external attack paths are now closed. Different gates close them, but the kill-shape is identical.\n\n## The pattern\n\nBoth hypotheses are built on per-handler asymmetry. Some handlers thread the param, others don't. Some handlers override the scope set, others don't. The asymmetry is real source-code, observable in five minutes of grep.\n\nBoth deaths land on a higher uniform enforcer. In one case, the threaded value has no downstream consumer because the decision moved upstream. In the other case, the additive scope can't reach the per-mutation override because the master gate filters first. Same lesson, two different mechanisms.\n\nThe actionable move at hypothesis-formation time is to locate the higher layer before drilling deeper into the asymmetry.\n\nFor phantom-plumbing risk, grep for READS of the threaded param, not just writes. `grep -rn 'scope_validator' app/ lib/` and look at what consumes the value, not what passes it along. If the only reads are the same files doing the writes, the plumbing is decorative and the actual decision happens elsewhere.\n\nFor master-gate-filter risk on additive scopes, grep for the endpoint-level scope authenticator. In GitLab that's `graphql_authorization_scopes`, `find_user_from_any_authentication_method`, `authenticate_sessionless_user!`, and `allow_access_with_scope`. Check whether the additive scope is in the master-allow set. If it isn't, the per-handler override is dead code from any path that goes through the master gate.\n\nFive minutes per check at hypothesis-formation, saves the lab-probe cycle.\n\n## When this rule does not apply\n\nA genuine asymmetry exists when the higher layer's enforcement is conditional or bypassed on a different path. If the master gate has a session-based bypass (cookie auth that skips token-scope check entirely) and the per-mutation override is reachable via session, the override matters. Internal-token paths (service tokens, CI job tokens, deploy tokens) often take a different authentication path that may not pass through the master gate either. Same caveat applies when the higher layer actually depends on the threaded value: if the master gate calls `validator.valid_for?(...)` with a validator whose identity flows from per-handler threading, per-handler asymmetry is exactly the right thing to audit.\n\nA calibration the rule needs: it kills sibling-of-CVE hypotheses, not the parent CVE itself. CVE-2026-1322 was real, the fix landed, the per-mutation `super + [:ai_workflows]` override pattern is the fix in action. Sibling-hunting on the post-fix shape is what dies. The original primitive was found on a different axis (the per-mutation scope-set was wrong, not the threading or the override).\n\n## The meta-pattern\n\nA lot of bug-hunting time goes into building sibling-of-CVE hypotheses on the assumption that the parent CVE's fix was narrow. Sometimes the fix really is narrow. But the fix often lives at a layer above the per-handler surface the parent CVE was reported on, and the per-handler artifacts (overrides, threaded params, conditional checks) are decoration around a uniform upstream decision. The audit's first move is to find the upstream decision-maker. If it's uniform, the per-handler asymmetry is uninteresting. If it's conditional on the per-handler delta, then the asymmetry is exactly the audit's target.\n\nFor GitLab-shaped Rails-with-GraphQL codebases, the upstream layer is at `lib/gitlab/auth/request_authenticator.rb`. Other vendors have analogs: an `AuthMiddleware`, a `RequestScopeFilter`, a `controller#before_action`. The names vary, the shape is constant.\n\n---\n\n_Methodology surfaced 2026-05-16 across two sibling-of-CVE-2026-1322 audits on GitLab GraphQL (CH-1' in S21, CH-NOVEL-3 in S22). Source citations verified against `gitlabhq/gitlabhq` main on 2026-05-18 via `gh api`. Both CH-1' and CH-NOVEL-3 closed as DEAD-END on lab probe. No filings._\n", "creation_timestamp": "2026-05-18T01:19:14.000000Z"}, {"uuid": "bdfc4f80-98c9-4642-b97b-867c3bd34f56", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "86ecb4e1-bb32-44d5-9f39-8a4673af8385", "vulnerability": "CVE-2026-1322", "type": "seen", "source": "https://www.hkcert.org/security-bulletin/gitlab-multiple-vulnerabilities_20260515", "content": "", "creation_timestamp": "2026-05-14T18:00:00.000000Z"}, {"uuid": "374474b0-8c88-4f4f-bbea-5f6698babd4a", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2026-1322", "type": "seen", "source": "https://gist.github.com/MarisollieNULL/26f21e0fc7e2450c25fb0f985ba97c9f", "content": "Two GitLab sibling-of-CVE hunts that died the same way: a higher layer enforcing uniformly. Phantom plumbing (a threaded param with no downstream consumer) and master gate filter (an additive scope that the endpoint authenticator excludes) are two mechanisms of the same shape. Five-minute upstream-grep beats lab-probe.\n\n# Find the Higher Layer Before You Chase the Asymmetry\n\n_Two GitLab sibling-of-CVE hunts that survived hypothesis-formation and both died at the same shape: a higher layer enforcing uniformly._\n\n## The setup\n\nCVE-2026-1322 was a GitLab GraphQL scope-bypass where `:read_api`-scoped tokens could execute a subset of mutations that should have required `:api`. The fix narrowed the per-mutation scope check. After it landed, I went sibling-hunting on the same surface twice: once on a pre-patch shape, once on a post-patch shape. Both hypotheses fired cleanly at hypothesis-formation, both survived their devil's-advocate paragraphs, both died at lab-probe. Both deaths landed on the same structural shape, which is what this writeup is about.\n\n## Hypothesis one, phantom plumbing\n\nGitLab forwards a `scope_validator` object from its REST API endpoints into the issuable service layer, and a few GraphQL mutations do the same. `lib/api/issues.rb:325` threads `scope_validator: context[:scope_validator]` into service params, and three GraphQL mutations (`app/graphql/mutations/work_items/create.rb:104-105`, `work_items/update.rb`, `notes/create/base.rb`) thread it analogously. Twenty-six other mutations on the same surface don't.\n\nThe asymmetry looks exploitable on first read. If the threaded validator is what enforces OAuth scope on quick-action sub-operations inside a mutation, then the twenty-six mutations that skip the threading would let a `:read_api` token execute scoped sub-operations they shouldn't. I built the first hypothesis on exactly that reading.\n\nThen I grepped for the validator's consumer. `app/services/issuable_base_service.rb:195-198` packages the validator into params and passes them into `QuickActions::InterpretService`. The InterpretService accepts the validator argument. So far the threading is real.\n\nBut `lib/gitlab/quick_actions/`, the DSL code that interprets every `/label` and `/assign` directive, has zero references to `scope_validator`. The service layer threads the object into the DSL layer, and the DSL layer never reads it. `app/services/quick_actions/interpret_service.rb:15` defines `QuickActionsNotAllowedError = Class.new(StandardError)`, and the error is never raised anywhere in the codebase.\n\nThe plumbing exists. Nothing reads it. The twenty-six-mutation asymmetry produces no exploitable surface because the scope decision happens upstream at `BaseMutation.authorized?` calling `scopes_ok?` before any mutation body runs. That's phantom plumbing: a security check prototyped or rolled back, the scaffolding kept, the actual check moved elsewhere.\n\n## Hypothesis two, master gate filter\n\nCVE-2026-1322's fix introduced a new scope, `:ai_workflows`, with the intent that AI-related mutations would be reachable by tokens carrying it. Four mutations override `authorization_scopes` to add the new scope to the default set:\n\n```ruby\n# app/graphql/mutations/work_items/create.rb:19-21\ndef self.authorization_scopes\n  super + [:ai_workflows]\nend\n```\n\nSame shape at `notes/create/note.rb`, `notes/update/note.rb`, and `work_items/update.rb`. And `:ai_workflows` is registered in `Gitlab::Auth.optional_scopes`, so Doorkeeper's OAuth-application registration accepts it as a valid scope. The UI scope-picker hides it; the backend service path accepts it via direct POST. I confirmed the asymmetry end-to-end on a lab GitLab Omnibus instance. A non-admin `victim` user registered an OAuth application requesting `scopes='ai_workflows'`, the `Applications::CreateService` accepted it, Doorkeeper issued a token.\n\nThe token works at the REST surface. `GET /api/v4/user` with the `Bearer ai_workflows_token` returns 200 and the user JSON. Per-mutation override is real, per-surface acceptance is real, the token is real.\n\nGraphQL says 401 \"Invalid token\" to every payload. Even a bare `{ currentUser { id } }`.\n\nRoot cause is at the master endpoint scope gate.\n\n```ruby\n# lib/gitlab/auth/request_authenticator.rb:93-95\ndef graphql_authorization_scopes\n  [:api, :read_api]\nend\n```\n\nThe master gate filters every token by its scope set before the per-mutation authorization check ever runs. `:ai_workflows`-only tokens get rejected at the GraphQL endpoint entry. The four-mutation `super + [:ai_workflows]` override is dead code from an external-attacker perspective. It exists for internal pipelines that already hold `:api` or `:read_api` alongside `:ai_workflows`, where the additive scope grants extra surface but isn't the only credential.\n\nBoth pre-patch (`:read_api` token) and post-patch (`:ai_workflows` token) external attack paths are now closed. Different gates close them, but the kill-shape is identical.\n\n## The pattern\n\nBoth hypotheses are built on per-handler asymmetry. Some handlers thread the param, others don't. Some handlers override the scope set, others don't. The asymmetry is real source-code, observable in five minutes of grep.\n\nBoth deaths land on a higher uniform enforcer. In one case, the threaded value has no downstream consumer because the decision moved upstream. In the other case, the additive scope can't reach the per-mutation override because the master gate filters first. Same lesson, two different mechanisms.\n\nThe actionable move at hypothesis-formation time is to locate the higher layer before drilling deeper into the asymmetry.\n\nFor phantom-plumbing risk, grep for READS of the threaded param, not just writes. `grep -rn 'scope_validator' app/ lib/` and look at what consumes the value, not what passes it along. If the only reads are the same files doing the writes, the plumbing is decorative and the actual decision happens elsewhere.\n\nFor master-gate-filter risk on additive scopes, grep for the endpoint-level scope authenticator. In GitLab that's `graphql_authorization_scopes`, `find_user_from_any_authentication_method`, `authenticate_sessionless_user!`, and `allow_access_with_scope`. Check whether the additive scope is in the master-allow set. If it isn't, the per-handler override is dead code from any path that goes through the master gate.\n\nFive minutes per check at hypothesis-formation, saves the lab-probe cycle.\n\n## When this rule does not apply\n\nA genuine asymmetry exists when the higher layer's enforcement is conditional or bypassed on a different path. If the master gate has a session-based bypass (cookie auth that skips token-scope check entirely) and the per-mutation override is reachable via session, the override matters. Internal-token paths (service tokens, CI job tokens, deploy tokens) often take a different authentication path that may not pass through the master gate either. Same caveat applies when the higher layer actually depends on the threaded value: if the master gate calls `validator.valid_for?(...)` with a validator whose identity flows from per-handler threading, per-handler asymmetry is exactly the right thing to audit.\n\nA calibration the rule needs: it kills sibling-of-CVE hypotheses, not the parent CVE itself. CVE-2026-1322 was real, the fix landed, the per-mutation `super + [:ai_workflows]` override pattern is the fix in action. Sibling-hunting on the post-fix shape is what dies. The original primitive was found on a different axis (the per-mutation scope-set was wrong, not the threading or the override).\n\n## The meta-pattern\n\nA lot of bug-hunting time goes into building sibling-of-CVE hypotheses on the assumption that the parent CVE's fix was narrow. Sometimes the fix really is narrow. But the fix often lives at a layer above the per-handler surface the parent CVE was reported on, and the per-handler artifacts (overrides, threaded params, conditional checks) are decoration around a uniform upstream decision. The audit's first move is to find the upstream decision-maker. If it's uniform, the per-handler asymmetry is uninteresting. If it's conditional on the per-handler delta, then the asymmetry is exactly the audit's target.\n\nFor GitLab-shaped Rails-with-GraphQL codebases, the upstream layer is at `lib/gitlab/auth/request_authenticator.rb`. Other vendors have analogs: an `AuthMiddleware`, a `RequestScopeFilter`, a `controller#before_action`. The names vary, the shape is constant.\n\n---\n\n_Sibling-of-CVE-2026-1322 audits on GitLab GraphQL, May 2026. Source citations verified against `gitlabhq/gitlabhq` main on May 18 via `gh api`. Both hypotheses closed without a finding on lab probe. No filings._\n", "creation_timestamp": "2026-05-18T11:49:46.000000Z"}]}