Skip to content

docs(rfc): disallow inline argument values (ENG-9586)#2998

Draft
jensneuse wants to merge 2 commits into
mainfrom
disallow-inline-args
Draft

docs(rfc): disallow inline argument values (ENG-9586)#2998
jensneuse wants to merge 2 commits into
mainfrom
disallow-inline-args

Conversation

@jensneuse

@jensneuse jensneuse commented Jun 23, 2026

Copy link
Copy Markdown
Member

Summary

RFC for an optional Cosmo Router policy that detects — and optionally rejects — GraphQL operations that carry hardcoded inline argument values instead of variables.

This PR adds the design document only (rfc/disallow-inline-args/RFC.md); no router code yet.

Design highlights

  • Detection during Parse() in graphql-go-tools parseArgumentList — no extra AST walk, sees the operation before any normalization, and rejects before any internal cache is written.
  • Comprehensive flagging: field arguments, directive arguments (@skip/@include and any directive), introspection-field arguments, and arguments inside selections that @skip/@include would normalize out.
  • Three modes: off (default) / enabled-non-enforcing (structured log + extensions migration hint on a successful response) / enabled-enforcing (reject with configurable HTTP status, error code, and message).
  • Persisted operations are exempt by default (stored server-side, intentional); opt in via include_persisted_operations.
  • Migration path off → log-only → enforce, with structured logs and an OTEL per-client attribute to find affected clients before enforcing.

Status

Draft — design review only. Open questions are tracked in §9 (subscriptions exemption, nested-path granularity, per-argument allowlist).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added an RFC describing a new router security policy that detects GraphQL operations with hardcoded inline argument literal values (instead of variables). Includes configurable modes for logging/annotation versus strict rejection, details on covered argument contexts, and an end-to-end testing plan.

RFC for an optional router policy that detects, and optionally rejects,
GraphQL operations carrying hardcoded inline argument values instead of
variables (ticket #2498 / ENG-9586).

Design highlights:
- Detection during Parse() in graphql-go-tools parseArgumentList: no extra
  walk, sees the un-normalized operation, rejects before any cache write.
- Comprehensive flagging: field args, directive args (@skip/@include),
  introspection args, and args that @skip/@include would normalize out.
- Three modes: off (default) / enabled-non-enforcing (log + extensions hint)
  / enabled-enforcing (configurable status code, error code, message).
- Persisted operations exempt by default; opt-in via
  include_persisted_operations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

A new RFC document is added at rfc/disallow-inline-args/RFC.md proposing a router security policy to detect GraphQL operations that use hardcoded inline argument literals instead of variables. The document defines detection timing, configuration, response formats, observability fields, edge cases, testing, and rejected alternatives.

Changes

Disallow Inline Arguments RFC

Layer / File(s) Summary
Purpose, goals, and operational modes
rfc/disallow-inline-args/RFC.md
Introduces the RFC, defines the operational modes, and lists explicit goals and non-goals.
Detection design and pipeline timing
rfc/disallow-inline-args/RFC.md
Specifies detection immediately after parsing and before normalization, defines the in-scope and excluded argument contexts, and describes persisted-operation behavior.
Configuration surface and response behavior
rfc/disallow-inline-args/RFC.md
Defines the security.disallow_inline_arguments configuration schema and defaults, registers the new error code, and specifies enforce-mode error output, non-enforcing response extensions, and off-mode no-op behavior.
Rollout, observability, and edge cases
rfc/disallow-inline-args/RFC.md
Documents rollout steps, structured logging, the OTEL span attribute, and edge cases and open questions.
Testing plan and rejected alternatives
rfc/disallow-inline-args/RFC.md
Lays out detector unit tests and router integration tests, then compares alternative detection approaches and records why they were rejected.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~15 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely matches the RFC’s main change: documenting a policy to disallow inline argument values.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands.

Comment thread rfc/disallow-inline-args/RFC.md Outdated
Comment on lines +141 to +163
1. Add an opt-in field to `Parser` (e.g. `reportInlineArguments bool` plus a `[]InlineArgumentInfo` accumulator),
settable per parse and reset at the start of each parse (alongside the parser's existing per-parse reset).
When the flag is off, the only cost is one predictable branch per argument — effectively free.

2. In `parseArgumentList`, immediately after `value := p.ParseValue()`,
record the argument when the flag is set and `value.Kind != ast.ValueKindVariable`.
Because this is the common path for field **and** directive arguments, it captures:
- field arguments (`userById(userId: "12345")`),
- directive arguments (`@include(if: true)`, `@skip(if: false)`, any directive),
- introspection-field arguments (`__type(name: "User")`),
- arguments inside fields/fragments that `@skip`/`@include` would later remove (nothing is pruned yet).

It naturally **excludes** variable-definition defaults (`$x: Int = 5`), because those are parsed
on a different path (variable-definition parsing), not through `parseArgumentList` — so the one
intended exclusion requires no special-casing.

```go
type InlineArgumentInfo struct {
ArgumentName string // e.g. "userId" — available as name.Literal at parser.go:430
ValueKind ast.ValueKind // e.g. ValueKindString — from value.Kind
InDirective string // directive name if this is a directive argument, else ""
Position position.Position
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure if it is a good idea to include this feature in the parser logic. I think we can just include the extra step in one of our validation walkers. Otherwise all parsing will additionally have to check each time if reportInlineArguments is set to true.

I'm personally against the recommended approach here

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.

I think we can do this much simpler. After parsing, we simply look into the AST and verify if we have any inline arguments that are not variables. If that's the case we flag this as error and stop the rest of the request pipeline. For that we don't need any visitors but just a single lookup in the request pipeline that we otherwise don't do. So the operation by itself is rather cheap and in case of disabled check a no op.

Comment thread rfc/disallow-inline-args/RFC.md Outdated
| # | Approach | Flags directive / introspection / skip-removed args? | Reject before 1st cache write? | Extra walk? | Verdict |
| --- | --- | --- | --- | --- | --- |
| A | Detect during parse in `parseArgumentList` (RECOMMENDED) | **Yes** — sees the un-normalized operation | **Yes** — runs during `Parse()`, before `NormalizeOperation` and any cache | **No** — rides the parse already performed | Chosen |
| B | Dedicated argument visitor over the parsed op, after `Parse()` | **Yes** — also sees the un-normalized operation | Yes | Yes — one extra argument-only walk | Fallback (see § 11) |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No sure if we need a dedicated visitor here, I think we already have some validation in place that could potentially be re-used? @devsergiy

}
}
]
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Something seems off here. We seem to have a path which is dot separated and the last element is the actual argument. not the enclosing field. while we have "argument" as well

so basically we have some kind of duplication here.
"path": "field.field.argument"
"argument": "argument"

If we use the proposed data structure as a reference to create errors we won't have the information about either path or enclosing field

type InlineArgumentInfo struct {
    ArgumentName string        // e.g. "userId" — available as name.Literal at parser.go:430
    ValueKind    ast.ValueKind // e.g. ValueKindString — from value.Kind
    InDirective  string        // directive name if this is a directive argument, else ""
    Position     position.Position
}

Comment thread rfc/disallow-inline-args/RFC.md Outdated
ArgumentName string // e.g. "userId" — available as name.Literal at parser.go:430
ValueKind ast.ValueKind // e.g. ValueKindString — from value.Kind
InDirective string // directive name if this is a directive argument, else ""
Position position.Position

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Positions might not be always correct, right? We sometimes have multiple stages of parsing where we also append to operation documents or rewrite certain things (e.g. astnormalization).

We might end up reparsing again for different visitors so the position might be different to the position in the original operation that was sent to the router.

userById(userId: $userId) {
loginName
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The idea is to not have too many distinct operations, but technically we could also have different variable names. I suppose this would be up to the user to use the feature properly? Or do we also want to flag use of different variable names for the same operations?

query GetUserById($userId1: ID!) {
  userById(userId: $userId2) {
    loginName
  }
}

query GetUserById($userId2: ID!) {
  userById(userId: $userId2) {
    loginName
  }
}

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.

we cannot prevent this

Adopt the simplest detection approach per reviewer feedback: after Parse(),
scan ast.Document.Arguments once in the router pipeline. No parser change,
no visitor, no graphql-go-tools change at all.

- jensneuse: single AST lookup, no visitors, no-op when disabled.
- Noroth: drop parser instrumentation (taxes every parse); remove path/argument
  duplication in the report struct; clarify positions are accurate (scan runs
  pre-normalization).
- Note variable-name variance cannot be prevented (out of scope).
- Demote parser instrumentation and validation-walker reuse to rejected
  alternatives; keep the dedicated visitor as the richer-info fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rfc/disallow-inline-args/RFC.md (1)

254-259: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve enforce-mode reporting contradiction (first-hit vs full list).

Line 254 and Line 288 define enforce mode as stopping on the first inline argument, but Line 629 expects enforce responses to report both first and if in one request. Please pick one contract and align §§4, 6, and 10 (plus examples) to avoid implementation/test drift.

Also applies to: 288-288, 629-630

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rfc/disallow-inline-args/RFC.md` around lines 254 - 259, The enforce-mode
contract is inconsistent across the RFC: some sections say scanning stops at the
first inline argument, while another example expects multiple violations like
`first` and `if` to be reported together. Pick one behavior for
`inlineArgumentsError`/`reportError` and update the related text in §§4, 6, and
10 plus the examples so `NormalizeOperation()` and the prehandler flow describe
the same single contract everywhere.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rfc/disallow-inline-args/RFC.md`:
- Around line 232-239: Add a language tag to the unlabeled fenced pipeline block
in the RFC so it satisfies markdown lint; update the fence around the
Parse/NormalizeOperation pipeline snippet to use a text-style language label,
keeping the content unchanged while making the docs tooling happy.

---

Outside diff comments:
In `@rfc/disallow-inline-args/RFC.md`:
- Around line 254-259: The enforce-mode contract is inconsistent across the RFC:
some sections say scanning stops at the first inline argument, while another
example expects multiple violations like `first` and `if` to be reported
together. Pick one behavior for `inlineArgumentsError`/`reportError` and update
the related text in §§4, 6, and 10 plus the examples so `NormalizeOperation()`
and the prehandler flow describe the same single contract everywhere.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ebb8e64b-8eb8-491a-b669-8b6a570360e1

📥 Commits

Reviewing files that changed from the base of the PR and between f576673 and 217eb53.

📒 Files selected for processing (1)
  • rfc/disallow-inline-args/RFC.md

Comment on lines +232 to +239
```
Parse (687)
=== scan doc.Arguments here (only when the feature is enabled) ===
=== enforce mode: stop the pipeline here, before any cache is consulted or written ===
NormalizeOperation (830) -> first cache write at :885
NormalizeVariables (855) -> variables cache
... RemapVariables / Validate / Plan, each with its own cache
```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language tag to the fenced pipeline block.

The code fence starting at Line 232 is unlabeled (```). Add a language (for example text) to satisfy markdown lint and keep docs tooling clean.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 232-232: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rfc/disallow-inline-args/RFC.md` around lines 232 - 239, Add a language tag
to the unlabeled fenced pipeline block in the RFC so it satisfies markdown lint;
update the fence around the Parse/NormalizeOperation pipeline snippet to use a
text-style language label, keeping the content unchanged while making the docs
tooling happy.

Source: Linters/SAST tools

Comment on lines +136 to 142
for ref := range o.kit.doc.Arguments {
if o.kit.doc.Arguments[ref].Value.Kind != ast.ValueKindVariable {
// inline argument found
// enforce mode: record it and stop the pipeline here
// log mode: collect and continue
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Imagine a more complex operation. Can we still provide good UX/DX here without knowing where the argument is coming from?

query GetDashboard(
  $tenantId: ID!
  $userId: ID!
  $limit: Int!
  $includeArchived: Boolean!
) @cacheControl(maxAge: 60, scope: "PRIVATE")
  @trace(enabled: true, sampleRate: 0.25)
{
  tenant(id: $tenantId)
    @auth(role: "admin")
    @cacheControl(maxAge: 30, scope: "PUBLIC")
  {
    id

    users(
      filter: { status: "ACTIVE", role: "admin" }
      limit: $limit
      orderBy: { field: "createdAt", direction: "DESC" }
    ) @include(if: true)
      @rateLimit(limit: 100, window: "1m")
    {
      id
      name

      posts(
        filter: { published: true, tag: "graphql" }
        limit: 10
      ) @skip(if: false)
      {
        id
        title

        comments(
          filter: { flagged: false }
          limit: 5
        ) @include(if: $includeArchived)
        {
          id
          text
        }
      }
    }

    auditLogs(
      filter: { actorId: $userId, action: "LOGIN" }
      limit: 50
    ) @cacheControl(maxAge: 5, scope: "PRIVATE")
    {
      id
      action
      createdAt
    }
  }
}

We would get something like:

{
  "extensions": {
    "inlineArguments": {
      "code": "INLINE_ARGUMENT_VALUES_NOT_ALLOWED",
      "message": "Inline argument values are not allowed. Use variables instead.",
      "arguments": [
        {
          "argument": "maxAge",
          "valueKind": "Int",
          "line": 5,
          "column": 30
        },
        {
          "argument": "scope",
          "valueKind": "String",
          "line": 5,
          "column": 43
        },
        {
          "argument": "role",
          "valueKind": "String",
          "line": 9,
          "column": 17
        },
        {
          "argument": "filter",
          "valueKind": "Object",
          "line": 14,
          "column": 7
        },
        {
          "argument": "limit",
          "valueKind": "Int",
          "line": 15,
          "column": 7
        },
        {
          "argument": "filter",
          "valueKind": "Object",
          "line": 24,
          "column": 9
        },
        {
          "argument": "limit",
          "valueKind": "Int",
          "line": 25,
          "column": 9
        },
        {
          "argument": "filter",
          "valueKind": "Object",
          "line": 34,
          "column": 11
        },
        {
          "argument": "limit",
          "valueKind": "Int",
          "line": 35,
          "column": 11
        }
      ]
    }
  }
}

I think something beter could be:

Field:

{
  "path": "tenant.users.posts",
  "kind": "Field",
  "name": "posts",
  "argument": "filter",
  "valueKind": "Object",
  "line": 24,
  "column": 9
}

Directive

{
  "path": "tenant.users.posts",
  "kind": "Directive",
  "name": "skip",
  "argument": "if",
  "valueKind": "Boolean",
  "line": 27,
  "column": 15
}

@gausie

gausie commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

The RFC specifies different shapes for the two modes, is that intentional?

In enforce mode, extensions.inlineArguments is a flat array (inside the GraphQL error object), with code and message at the extensions level. In non-enforce mode, extensions.inlineArguments is an object with code, message, and a nested arguments array.

Clients reading the argument list need to branch on mode: extensions.inlineArguments in enforce vs extensions.inlineArguments.arguments in non-enforce. If the difference is intentional, worth documenting why. If not, which shape should both converge to?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants