diff --git a/docs-website/docs.json b/docs-website/docs.json
index b9a4c109ee..9bf12830dd 100644
--- a/docs-website/docs.json
+++ b/docs-website/docs.json
@@ -234,7 +234,8 @@
"router/security/tls",
"router/security/config-validation-and-signing",
"router/security/hardening-guide",
- "router/security/cost-control"
+ "router/security/cost-control",
+ "router/security/validate-inline-arguments"
]
},
{
diff --git a/docs-website/router/configuration.mdx b/docs-website/router/configuration.mdx
index 062a9d0fac..d8c1f00857 100644
--- a/docs-website/router/configuration.mdx
+++ b/docs-website/router/configuration.mdx
@@ -2102,6 +2102,35 @@ engine:
always_skip_loader: false
```
+### Validate Inline Arguments
+
+The configuration for [Validate Inline Arguments](/router/security/validate-inline-arguments). Detects, and
+optionally rejects, operations that pass argument values inline instead of through variables.
+
+| Environment Variable | YAML | Required | Description | Default Value |
+|-----------------------------------------------------------------|---------------------------------|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------|
+| ENGINE_VALIDATE_INLINE_ARGUMENTS_MODE | mode | | `off` disables the feature; `permissive` detects and records inline arguments while still executing; `strict` rejects operations that use inline argument values. | off |
+| ENGINE_VALIDATE_INLINE_ARGUMENTS_ENFORCE_HTTP_STATUS_CODE | enforce_http_status_code | | HTTP status code returned when an operation is rejected in enforcing mode. | 400 |
+| ENGINE_VALIDATE_INLINE_ARGUMENTS_ERROR_CODE | error_code | | The `extensions.code` set on the rejection error. | INLINE_ARGUMENT_VALUES_NOT_ALLOWED |
+| ENGINE_VALIDATE_INLINE_ARGUMENTS_ERROR_MESSAGE | error_message | | The error message returned to the client on rejection. | Inline argument values are not allowed. Use variables instead. |
+| ENGINE_VALIDATE_INLINE_ARGUMENTS_INCLUDE_PERSISTED_OPERATIONS | include_persisted_operations | | When true, the policy also applies to persisted operations. Persisted operations are exempt by default. | false |
+| ENGINE_VALIDATE_INLINE_ARGUMENTS_RETURN_IN_RESPONSE_EXTENSIONS | return_in_response_extensions | | When true, detected inline arguments are returned to the client under `extensions.inlineArguments`. Applies only in non-enforcing mode. | false |
+
+#### Example YAML config:
+
+```yaml config.yaml
+version: "1"
+
+engine:
+ validate_inline_arguments:
+ mode: permissive
+ enforce_http_status_code: 400
+ error_code: INLINE_ARGUMENT_VALUES_NOT_ALLOWED
+ error_message: "Inline argument values are not allowed. Use variables instead."
+ include_persisted_operations: false
+ return_in_response_extensions: false
+```
+
## Rate Limiting
Configures a rate limiter on the outgoing subgraphs requests. When enabled, a rate of 10 req/s with a burst of 10 requests is configured.
diff --git a/docs-website/router/security/validate-inline-arguments.mdx b/docs-website/router/security/validate-inline-arguments.mdx
new file mode 100644
index 0000000000..bf3d77c0b6
--- /dev/null
+++ b/docs-website/router/security/validate-inline-arguments.mdx
@@ -0,0 +1,176 @@
+---
+title: "Validate Inline Arguments"
+description: "Detect, and optionally reject, operations that pass argument values inline instead of through variables."
+icon: shield-halved
+---
+
+## Overview
+
+An inline argument is an argument whose value is written directly into the operation as a literal
+instead of being supplied through a variable.
+
+```graphql
+# Inline argument: the value "12345" is hardcoded in the operation.
+query GetUser {
+ userById(userId: "12345") {
+ name
+ }
+}
+
+# Compliant: the value arrives through a variable.
+query GetUser($userId: ID!) {
+ userById(userId: $userId) {
+ name
+ }
+}
+```
+
+Inline argument values make operations harder to cache and observe. Every distinct literal produces a
+distinct operation, which lowers plan cache hit rates. Literals can also embed sensitive data (IDs, tokens,
+filter values) directly into query strings, where it ends up in logs and traces. Requiring variables keeps
+the operation shape stable and moves the values into the variables payload.
+
+`validate_inline_arguments` detects these arguments during operation normalization. Depending on the mode,
+the router logs the findings, returns them to the client, or rejects the operation.
+
+## Modes
+
+The feature has three modes:
+
+| Mode | Behavior |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
+| `off` | The feature is disabled. This is the default. |
+| `permissive` | The router detects inline arguments and records them. The operation still executes. Use this to observe traffic before enforcing. |
+| `strict` | The router rejects any operation that uses an inline argument value. No subgraph requests are made. |
+
+## Configuration
+
+The feature is configured under `engine.validate_inline_arguments`:
+
+```yaml
+engine:
+ validate_inline_arguments:
+ mode: permissive
+ enforce_http_status_code: 400
+ error_code: INLINE_ARGUMENT_VALUES_NOT_ALLOWED
+ error_message: "Inline argument values are not allowed. Use variables instead."
+ include_persisted_operations: false
+ return_in_response_extensions: false
+```
+
+These options are also listed in more detail in the [router configuration reference](/router/configuration#validate-inline-arguments).
+
+## What counts as an inline argument
+
+Detection runs during normalization and covers every argument whose value is not a variable:
+
+- Field arguments, including introspection fields such as `__type(name: "User")`.
+- Directive arguments, including the built-in `@skip` and `@include` and any custom directive.
+
+The following are not flagged:
+
+- Arguments supplied through a variable, for example `userById(userId: $userId)`.
+- Variable default values, for example `query($first: Int = 10)`. A default value is not an argument.
+
+Detection happens before `@skip` and `@include` prune the selection set. An inline argument on a field or
+directive that normalization later removes is still reported.
+
+## Qualified names
+
+Findings are reported using a qualified name that identifies where the argument was used:
+
+| Context | Format | Example |
+| ----------------- | ------------------ | --------------- |
+| Field argument | `field.argument` | `userById.userId` |
+| Directive argument | `@directive.argument` | `@include.if` |
+
+## Non-enforcing mode
+
+In `permissive` mode the operation executes normally. The router surfaces the findings in two ways.
+
+### Warning log
+
+For every operation that contains inline arguments, the router emits a warning. This happens regardless of
+the `return_in_response_extensions` setting.
+
+```json
+{
+ "level": "warn",
+ "msg": "Inline argument values found in operation; use variables instead",
+ "count": 2,
+ "arguments": ["userById.userId", "@include.if"],
+ "operation_name": "GetUser",
+ "operation_hash": 1234567890
+}
+```
+
+### Response extensions
+
+When `return_in_response_extensions` is `true`, the findings are also returned to the client under
+`extensions.inlineArguments`. The `count` field is the number of inline arguments, and `arguments` lists their
+qualified names.
+
+```json
+{
+ "data": { "userById": { "name": "Me" } },
+ "extensions": {
+ "inlineArguments": {
+ "count": 2,
+ "arguments": ["userById.userId", "@include.if"]
+ }
+ }
+}
+```
+
+`return_in_response_extensions` has no effect in enforcing mode, because offending operations are rejected
+before a response is produced.
+
+## Enforcing mode
+
+In `strict` mode the router rejects any operation that uses an inline argument value. The operation
+is rejected during normalization, so no subgraph requests are made.
+
+The rejection is generic. The router stops at the first inline argument and does not name the argument or
+point at its location. The response uses the configured `error_message`, `error_code`, and
+`enforce_http_status_code`.
+
+```json
+{
+ "errors": [
+ {
+ "message": "Inline argument values are not allowed. Use variables instead.",
+ "extensions": {
+ "code": "INLINE_ARGUMENT_VALUES_NOT_ALLOWED"
+ }
+ }
+ ]
+}
+```
+
+## Persisted operations
+
+Persisted operations are exempt by default. They are authored and registered ahead of time, so inline values
+in a persisted operation are a controlled, reviewable input rather than arbitrary client traffic.
+
+Set `include_persisted_operations: true` to apply the policy to persisted operations as well. This applies to
+both non-enforcing and enforcing modes.
+
+## Rollout
+
+Start in `permissive` mode to understand your traffic without breaking clients.
+
+```yaml
+engine:
+ validate_inline_arguments:
+ mode: permissive
+ return_in_response_extensions: true
+```
+
+Use the warning logs and `extensions.inlineArguments` to find operations that still send inline values and
+migrate them to variables. Once the offending operations are gone, switch to `strict`.
+
+```yaml
+engine:
+ validate_inline_arguments:
+ mode: strict
+```
diff --git a/router-tests/go.mod b/router-tests/go.mod
index ea089aa5ce..fb7d3e15a5 100644
--- a/router-tests/go.mod
+++ b/router-tests/go.mod
@@ -3,7 +3,7 @@ module github.com/wundergraph/cosmo/router-tests
go 1.25.0
require (
- connectrpc.com/connect v1.19.1
+ connectrpc.com/connect v1.19.2
github.com/MicahParks/jwkset v0.11.0
github.com/buger/jsonparser v1.1.2
github.com/cloudflare/backoff v0.0.0-20240920015135-e46b80a3a7d0
@@ -31,7 +31,7 @@ require (
github.com/wundergraph/cosmo/router v0.0.0-20260710155145-803a4bc06d92
github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e
github.com/wundergraph/cosmo/speedtrap v0.0.0-00010101000000-000000000000
- github.com/wundergraph/graphql-go-tools/v2 v2.10.0
+ github.com/wundergraph/graphql-go-tools/v2 v2.12.1
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
diff --git a/router-tests/go.sum b/router-tests/go.sum
index 36c1041504..b19b3cd1bf 100644
--- a/router-tests/go.sum
+++ b/router-tests/go.sum
@@ -1,5 +1,5 @@
-connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
-connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
+connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
+connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
connectrpc.com/vanguard v0.3.0 h1:prUKFm8rYDwvpvnOSoqdUowPMK0tRA0pbSrQoMd6Zng=
connectrpc.com/vanguard v0.3.0/go.mod h1:nxQ7+N6qhBiQczqGwdTw4oCqx1rDryIt20cEdECqToM=
github.com/99designs/gqlgen v0.17.76 h1:YsJBcfACWmXWU2t1yCjoGdOmqcTfOFpjbLAE443fmYI=
@@ -381,8 +381,8 @@ github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLV
github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw=
github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8=
github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw=
-github.com/wundergraph/graphql-go-tools/v2 v2.10.0 h1:hAzNsXbzbSTOeD3VcRFeHphLj5S0z5j7F2VaZbtkygs=
-github.com/wundergraph/graphql-go-tools/v2 v2.10.0/go.mod h1:rGG9m74sUyucfvSZ83Mjuq/6qRJetl1CVP872f/dCok=
+github.com/wundergraph/graphql-go-tools/v2 v2.12.1 h1:wds1aBlnml86PFhgK21sOqzJOb9eOfVF3mK8NLDxEVY=
+github.com/wundergraph/graphql-go-tools/v2 v2.12.1/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
diff --git a/router-tests/operations/disallow_inline_arguments_test.go b/router-tests/operations/disallow_inline_arguments_test.go
new file mode 100644
index 0000000000..b53ebea358
--- /dev/null
+++ b/router-tests/operations/disallow_inline_arguments_test.go
@@ -0,0 +1,391 @@
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap/zapcore"
+
+ "github.com/wundergraph/cosmo/router-tests/testenv"
+ "github.com/wundergraph/cosmo/router/pkg/config"
+)
+
+func TestValidateInlineArguments(t *testing.T) {
+ t.Parallel()
+
+ const inlineArgumentQuery = `query GetEmployee { employee(id: 1) { id } }`
+ const variableQuery = `query GetEmployee($id: Int!) { employee(id: $id) { id } }`
+
+ t.Run("off (default) executes inline-argument operations normally", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{}, func(t *testing.T, xEnv *testenv.Environment) {
+ res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: inlineArgumentQuery})
+ require.Equal(t, http.StatusOK, res.Response.StatusCode)
+ require.Equal(t, `{"data":{"employee":{"id":1}}}`, res.Body)
+ })
+ })
+
+ t.Run("enforcing rejects an inline field argument", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModeStrict,
+ EnforceHTTPStatusCode: 400,
+ ErrorCode: "INLINE_ARGUMENT_VALUES_NOT_ALLOWED",
+ ErrorMessage: "Inline argument values are not allowed. Use variables instead.",
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ res, err := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{Query: inlineArgumentQuery})
+ require.NoError(t, err)
+ require.Equal(t, http.StatusBadRequest, res.Response.StatusCode)
+ // The rejection is a generic error: no argument name and no location.
+ require.Equal(t, `{"errors":[{"message":"Inline argument values are not allowed. Use variables instead.","extensions":{"code":"INLINE_ARGUMENT_VALUES_NOT_ALLOWED"}}]}`, res.Body)
+ })
+ })
+
+ t.Run("enforcing stays a generic error even when return_in_response_extensions is set", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModeStrict,
+ EnforceHTTPStatusCode: 400,
+ ErrorCode: "INLINE_ARGUMENT_VALUES_NOT_ALLOWED",
+ ErrorMessage: "Inline argument values are not allowed. Use variables instead.",
+ ReturnInResponseExtensions: true,
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ res, err := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{Query: inlineArgumentQuery})
+ require.NoError(t, err)
+ require.Equal(t, http.StatusBadRequest, res.Response.StatusCode)
+ // return_in_response_extensions only affects non-enforcing mode; the
+ // enforce rejection stays generic — no argument name, no location.
+ require.JSONEq(t, `{"errors":[{"message":"Inline argument values are not allowed. Use variables instead.","extensions":{"code":"INLINE_ARGUMENT_VALUES_NOT_ALLOWED"}}]}`, res.Body)
+ })
+ })
+
+ t.Run("enforcing passes a compliant operation using variables", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModeStrict,
+ EnforceHTTPStatusCode: 400,
+ ErrorCode: "INLINE_ARGUMENT_VALUES_NOT_ALLOWED",
+ ErrorMessage: "Inline argument values are not allowed. Use variables instead.",
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
+ Query: variableQuery,
+ Variables: []byte(`{"id":1}`),
+ })
+ require.Equal(t, http.StatusOK, res.Response.StatusCode)
+ require.Equal(t, `{"data":{"employee":{"id":1}}}`, res.Body)
+ })
+ })
+
+ t.Run("non-enforcing executes the operation and logs a warning", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ LogObservation: testenv.LogObservationConfig{
+ Enabled: true,
+ LogLevel: zapcore.WarnLevel,
+ },
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModePermissive,
+ ErrorCode: "INLINE_ARGUMENT_VALUES_NOT_ALLOWED",
+ ErrorMessage: "Inline argument values are not allowed. Use variables instead.",
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: inlineArgumentQuery})
+ require.Equal(t, http.StatusOK, res.Response.StatusCode)
+ require.Equal(t, `{"data":{"employee":{"id":1}}}`, res.Body)
+
+ warnings := xEnv.Observer().FilterMessage("Inline argument values found in operation; use variables instead").All()
+ require.Len(t, warnings, 1)
+ fields := warnings[0].ContextMap()
+ assert.EqualValues(t, 1, fields["count"])
+ // Enclosing field context comes from the walker.
+ assert.Equal(t, []any{"query.employee#id"}, fields["arguments"])
+ assert.Equal(t, "GetEmployee", fields["operation_name"])
+ })
+ })
+
+ t.Run("non-enforcing warns on a normalization cache hit too", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ LogObservation: testenv.LogObservationConfig{
+ Enabled: true,
+ LogLevel: zapcore.WarnLevel,
+ },
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModePermissive,
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ // Send the same inline-argument operation twice. The second request
+ // hits the normalization cache, but the warning must still fire because
+ // the findings are restored from the cache entry.
+ for i := 0; i < 2; i++ {
+ res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: inlineArgumentQuery})
+ require.Equal(t, `{"data":{"employee":{"id":1}}}`, res.Body)
+ }
+
+ warnings := xEnv.Observer().FilterMessage("Inline argument values found in operation; use variables instead").All()
+ require.Len(t, warnings, 2, "warning must fire on both the cache-miss and the cache-hit request")
+ })
+ })
+
+ t.Run("non-enforcing does not warn for compliant operations", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ LogObservation: testenv.LogObservationConfig{
+ Enabled: true,
+ LogLevel: zapcore.WarnLevel,
+ },
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModePermissive,
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
+ Query: variableQuery,
+ Variables: []byte(`{"id":1}`),
+ })
+ require.Equal(t, `{"data":{"employee":{"id":1}}}`, res.Body)
+
+ warnings := xEnv.Observer().FilterMessage("Inline argument values found in operation; use variables instead").All()
+ require.Empty(t, warnings)
+ })
+ })
+
+ // The WebSocket handler runs its own normalization path (core/websocket.go),
+ // separate from the HTTP prehandler. Non-enforcing detection must warn there
+ // too, so operations sent over WebSockets are not silently exempt.
+ t.Run("non-enforcing warns for an operation sent over a WebSocket", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ LogObservation: testenv.LogObservationConfig{
+ Enabled: true,
+ LogLevel: zapcore.WarnLevel,
+ },
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModePermissive,
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ conn := xEnv.InitGraphQLWebSocketConnection(nil, nil, nil)
+ err := testenv.WSWriteJSON(t, conn, testenv.WebSocketMessage{
+ ID: "1",
+ Type: "subscribe",
+ Payload: []byte(`{"query":"` + inlineArgumentQuery + `"}`),
+ })
+ require.NoError(t, err)
+
+ var res testenv.WebSocketMessage
+ err = testenv.WSReadJSON(t, conn, &res)
+ require.NoError(t, err)
+ require.Equal(t, "next", res.Type)
+ require.JSONEq(t, `{"data":{"employee":{"id":1}}}`, string(res.Payload))
+
+ var complete testenv.WebSocketMessage
+ err = testenv.WSReadJSON(t, conn, &complete)
+ require.NoError(t, err)
+ require.Equal(t, "complete", complete.Type)
+
+ // Normalization (and the warning) runs before the response is produced,
+ // so once "complete" arrives the log entry is guaranteed present.
+ warnings := xEnv.Observer().FilterMessage("Inline argument values found in operation; use variables instead").All()
+ require.Len(t, warnings, 1)
+ fields := warnings[0].ContextMap()
+ assert.EqualValues(t, 1, fields["count"])
+ assert.Equal(t, []any{"query.employee#id"}, fields["arguments"])
+ assert.Equal(t, "GetEmployee", fields["operation_name"])
+ })
+ })
+
+ t.Run("non-enforcing returns inline arguments in response extensions when configured", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModePermissive,
+ ReturnInResponseExtensions: true,
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ // Send the same inline-argument operation twice. The second request hits
+ // the normalization cache; the extension must still surface because the
+ // findings are restored from the cache entry (same as the warning log).
+ const wantBody = `{"data":{"employee":{"id":1}},"extensions":{"inlineArguments":{"count":1,"arguments":["query.employee#id"]}}}`
+ for i := 0; i < 2; i++ {
+ res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: inlineArgumentQuery})
+ require.Equal(t, http.StatusOK, res.Response.StatusCode)
+ require.JSONEq(t, wantBody, res.Body, "extension must surface on both cache-miss and cache-hit")
+ }
+
+ // A compliant operation must not carry the extension.
+ resOK := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
+ Query: variableQuery,
+ Variables: []byte(`{"id":1}`),
+ })
+ require.Equal(t, `{"data":{"employee":{"id":1}}}`, resOK.Body)
+ })
+ })
+
+ t.Run("non-enforcing omits the extension when reporting is disabled", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModePermissive,
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{Query: inlineArgumentQuery})
+ require.Equal(t, `{"data":{"employee":{"id":1}}}`, res.Body)
+ })
+ })
+
+ t.Run("non-enforcing returns the extension over a WebSocket when configured", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModePermissive,
+ ReturnInResponseExtensions: true,
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ conn := xEnv.InitGraphQLWebSocketConnection(nil, nil, nil)
+ err := testenv.WSWriteJSON(t, conn, testenv.WebSocketMessage{
+ ID: "1",
+ Type: "subscribe",
+ Payload: []byte(`{"query":"` + inlineArgumentQuery + `"}`),
+ })
+ require.NoError(t, err)
+
+ var res testenv.WebSocketMessage
+ err = testenv.WSReadJSON(t, conn, &res)
+ require.NoError(t, err)
+ require.Equal(t, "next", res.Type)
+ require.JSONEq(t, `{"data":{"employee":{"id":1}},"extensions":{"inlineArguments":{"count":1,"arguments":["query.employee#id"]}}}`, string(res.Payload))
+ })
+ })
+}
+
+// TestValidateInlineArgumentsPersistedOperations covers the persisted-operation
+// exemption. The persisted operation 4000...0000 ("MyQuery") contains a single
+// inline argument (employee(id: 1)); its `$yes` is a variable-definition default
+// (excluded) and its @include uses a variable (compliant).
+func TestValidateInlineArgumentsPersistedOperations(t *testing.T) {
+ t.Parallel()
+
+ const persistedInlineArgHash = "4000000000000000000000000000000000000000000000000000000000000000"
+ const okBody = `{"data":{"employee":{"details":{"forename":"Jens","surname":"Neuse"}}}}`
+
+ persistedInlineArgRequest := func() testenv.GraphQLRequest {
+ header := make(http.Header)
+ header.Add("graphql-client-name", "my-client")
+ return testenv.GraphQLRequest{
+ OperationName: []byte(`"MyQuery"`),
+ Extensions: []byte(`{"persistedQuery": {"version": 1, "sha256Hash": "` + persistedInlineArgHash + `"}}`),
+ Header: header,
+ Variables: []byte(`{}`),
+ }
+ }
+
+ t.Run("enforcing exempts persisted operations by default", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModeStrict,
+ EnforceHTTPStatusCode: 400,
+ ErrorCode: "INLINE_ARGUMENT_VALUES_NOT_ALLOWED",
+ ErrorMessage: "Inline argument values are not allowed. Use variables instead.",
+ // IncludePersistedOperations defaults to false — persisted ops are exempt.
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ res, err := xEnv.MakeGraphQLRequest(persistedInlineArgRequest())
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, res.Response.StatusCode)
+ require.Equal(t, okBody, res.Body)
+ })
+ })
+
+ t.Run("enforcing rejects persisted operations when include_persisted_operations is true", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModeStrict,
+ EnforceHTTPStatusCode: 400,
+ ErrorCode: "INLINE_ARGUMENT_VALUES_NOT_ALLOWED",
+ ErrorMessage: "Inline argument values are not allowed. Use variables instead.",
+ IncludePersistedOperations: true,
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ res, err := xEnv.MakeGraphQLRequest(persistedInlineArgRequest())
+ require.NoError(t, err)
+ require.Equal(t, http.StatusBadRequest, res.Response.StatusCode)
+ // The rejection is a generic error: no argument name and no location.
+ require.Equal(t, `{"errors":[{"message":"Inline argument values are not allowed. Use variables instead.","extensions":{"code":"INLINE_ARGUMENT_VALUES_NOT_ALLOWED"}}]}`, res.Body)
+ })
+ })
+
+ t.Run("non-enforcing exempts persisted operations by default (no warning)", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ LogObservation: testenv.LogObservationConfig{Enabled: true, LogLevel: zapcore.WarnLevel},
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModePermissive,
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ res, err := xEnv.MakeGraphQLRequest(persistedInlineArgRequest())
+ require.NoError(t, err)
+ require.Equal(t, okBody, res.Body)
+
+ warnings := xEnv.Observer().FilterMessage("Inline argument values found in operation; use variables instead").All()
+ require.Empty(t, warnings)
+ })
+ })
+
+ t.Run("non-enforcing warns for persisted operations when included", func(t *testing.T) {
+ t.Parallel()
+ testenv.Run(t, &testenv.Config{
+ LogObservation: testenv.LogObservationConfig{Enabled: true, LogLevel: zapcore.WarnLevel},
+ ModifyEngineExecutionConfiguration: func(s *config.EngineExecutionConfiguration) {
+ s.ValidateInlineArguments = config.ValidateInlineArguments{
+ Mode: config.EnforcementModePermissive,
+ IncludePersistedOperations: true,
+ }
+ },
+ }, func(t *testing.T, xEnv *testenv.Environment) {
+ res, err := xEnv.MakeGraphQLRequest(persistedInlineArgRequest())
+ require.NoError(t, err)
+ require.Equal(t, okBody, res.Body)
+
+ warnings := xEnv.Observer().FilterMessage("Inline argument values found in operation; use variables instead").All()
+ require.Len(t, warnings, 1)
+ fields := warnings[0].ContextMap()
+ assert.EqualValues(t, 1, fields["count"])
+ assert.Equal(t, []any{"query.employee#id"}, fields["arguments"])
+ })
+ })
+}
diff --git a/router-tests/protocol/router_plugin_test.go b/router-tests/protocol/router_plugin_test.go
index 19a0dbae44..8bf3ac588a 100644
--- a/router-tests/protocol/router_plugin_test.go
+++ b/router-tests/protocol/router_plugin_test.go
@@ -62,12 +62,12 @@ func TestRouterPlugin(t *testing.T) {
response1 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `query { project(id: 1) { id } }`,
})
- require.Equal(t, `{"errors":[{"message":"Failed to fetch from Subgraph 'projects'.","extensions":{"errors":[{"message":"gRPC datasource needs to be enabled to be used","extensions":{"code":"Internal"}}]}}],"data":{"project":null}}`, response1.Body)
+ require.Equal(t, `{"errors":[{"message":"Failed to fetch from Subgraph 'projects'.","extensions":{"errors":[{"message":"gRPC / connect datasource needs to be enabled to be used","extensions":{"code":"Internal"}}]}}],"data":{"project":null}}`, response1.Body)
response2 := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `query { course(id: 1) { id } }`,
})
- require.Equal(t, `{"errors":[{"message":"Failed to fetch from Subgraph 'courses'.","extensions":{"errors":[{"message":"gRPC datasource needs to be enabled to be used","extensions":{"code":"Internal"}}]}}],"data":{"course":null}}`, response2.Body)
+ require.Equal(t, `{"errors":[{"message":"Failed to fetch from Subgraph 'courses'.","extensions":{"errors":[{"message":"gRPC / connect datasource needs to be enabled to be used","extensions":{"code":"Internal"}}]}}],"data":{"course":null}}`, response2.Body)
})
})
diff --git a/router/core/context.go b/router/core/context.go
index 5bdf0c52c4..bda68f50c1 100644
--- a/router/core/context.go
+++ b/router/core/context.go
@@ -631,6 +631,7 @@ type operationContext struct {
planCacheHit bool
initialPayload []byte
extensions []byte
+ inlineArguments []string
persistedID string
// Hash on the original operation
sha256Hash string
diff --git a/router/core/graph_server.go b/router/core/graph_server.go
index f9298913ad..8c5182ce44 100644
--- a/router/core/graph_server.go
+++ b/router/core/graph_server.go
@@ -1599,9 +1599,10 @@ func (s *graphServer) buildGraphMux(
ApolloRouterCompatibilityFlags: s.apolloRouterCompatibilityFlags,
DisableExposingVariablesContentOnValidationError: s.engineExecutionConfiguration.DisableExposingVariablesContentOnValidationError,
RelaxSubgraphOperationFieldSelectionMergingNullability: s.engineExecutionConfiguration.RelaxSubgraphOperationFieldSelectionMergingNullability,
- EnableDefer: s.engineExecutionConfiguration.EnableDefer,
- ComplexityLimits: s.securityConfiguration.ComplexityLimits,
- CostControl: s.securityConfiguration.CostControl,
+ EnableDefer: s.engineExecutionConfiguration.EnableDefer,
+ ComplexityLimits: s.securityConfiguration.ComplexityLimits,
+ CostControl: s.securityConfiguration.CostControl,
+ ValidateInlineArguments: s.engineExecutionConfiguration.ValidateInlineArguments,
})
if opts.ReloadPersistentState.inMemoryPlanCacheFallback.IsEnabled() {
diff --git a/router/core/graphql_handler.go b/router/core/graphql_handler.go
index c058f6bbdb..4ef92da46b 100644
--- a/router/core/graphql_handler.go
+++ b/router/core/graphql_handler.go
@@ -167,6 +167,7 @@ func (h *GraphQLHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
resolveCtx.TracingOptions = reqCtx.operation.traceOptions
resolveCtx.InitialPayload = reqCtx.operation.initialPayload
resolveCtx.Extensions = reqCtx.operation.extensions
+ resolveCtx.InlineArguments = reqCtx.operation.inlineArguments
resolveCtx.ExecutionOptions = reqCtx.operation.executionOptions
if h.headerPropagation != nil {
diff --git a/router/core/graphql_prehandler.go b/router/core/graphql_prehandler.go
index 8951047760..9c237a4ecc 100644
--- a/router/core/graphql_prehandler.go
+++ b/router/core/graphql_prehandler.go
@@ -895,6 +895,11 @@ func (h *PreHandler) handleOperation(req *http.Request, httpOperation *httpOpera
requestContext.operation.variablesNormalizationCacheHit = cached
requestContext.expressionContext.Request.Operation.VariablesNormalizationCacheHit = cached
+ logInlineArguments(requestContext.logger, operationKit.parsedOperation)
+ if h.operationProcessor.parseKitOptions.validateInlineArguments.ReturnInResponseExtensions {
+ requestContext.operation.inlineArguments = inlineArgumentQualifiedNames(operationKit.parsedOperation)
+ }
+
// Update file upload paths if they were used in the nested field of the extracted variables.
for mapping := range slices.Values(uploadsMapping) {
// If the NewUploadPath is empty, there was no change in the path:
@@ -1424,3 +1429,35 @@ func setExpressionContextClient(requestContext *requestContext) {
requestContext.expressionContext.Request.Client.Version = clientVersion
}
}
+
+// logInlineArguments emits a warning listing every inline argument value found in
+// the operation (non-enforcing mode of ValidateInlineArguments). It is a no-op
+// when there are no findings, so both the HTTP prehandler and the WebSocket
+// handler can call it unconditionally after a successful normalization.
+func logInlineArguments(logger *zap.Logger, operation *ParsedOperation) {
+ argumentNames := inlineArgumentQualifiedNames(operation)
+ if len(argumentNames) == 0 {
+ return
+ }
+ logger.Warn("Inline argument values found in operation; use variables instead",
+ zap.Int("count", len(argumentNames)),
+ zap.Strings("arguments", argumentNames),
+ zap.String("operation_name", operation.Request.OperationName),
+ zap.Uint64("operation_hash", operation.ID),
+ )
+}
+
+// inlineArgumentQualifiedNames returns the qualified names (e.g. "user.id",
+// "@skip.if") of every inline argument found in the operation, or nil when there
+// are none. Shared by the warning log and the response-extension reporting.
+func inlineArgumentQualifiedNames(operation *ParsedOperation) []string {
+ inlineArguments := operation.InlineArguments
+ if len(inlineArguments) == 0 {
+ return nil
+ }
+ argumentNames := make([]string, len(inlineArguments))
+ for i, arg := range inlineArguments {
+ argumentNames[i] = arg.QualifiedName()
+ }
+ return argumentNames
+}
diff --git a/router/core/operation_processor.go b/router/core/operation_processor.go
index cd58375ae1..656abdd66d 100644
--- a/router/core/operation_processor.go
+++ b/router/core/operation_processor.go
@@ -78,6 +78,8 @@ type ParsedOperation struct {
// NormalizationCacheHit is set to true if the request is a non-persisted operation,
// and the normalized operation was loaded from cache.
NormalizationCacheHit bool
+
+ InlineArguments []astnormalization.InlineArgument
}
func (o *ParsedOperation) IDString() string {
@@ -131,6 +133,7 @@ type OperationProcessorOptions struct {
ParserTokenizerLimits astparser.TokenizerLimits
OperationNameLengthLimit int
EnableDefer bool
+ ValidateInlineArguments config.ValidateInlineArguments
}
// OperationProcessor provides shared resources to the parseKit and OperationKit.
@@ -168,6 +171,11 @@ type parseKit struct {
normalizedOperation *bytes.Buffer
variablesValidator *variablesvalidation.VariablesValidator
operationValidator *astvalidation.OperationValidator
+
+ // inlineArgumentsIncludePersisted controls whether persisted operations are
+ // subject to inline-argument detection. When false, persisted operations are
+ // exempted per run via astnormalization.RunOptions.SkipInlineArguments.
+ inlineArgumentsIncludePersisted bool
}
type OperationCache struct {
@@ -721,6 +729,19 @@ func (o *OperationKit) Parse() error {
return nil
}
+func (o *OperationKit) inlineArgumentsRunOptions() astnormalization.RunOptions {
+ return astnormalization.RunOptions{
+ SkipInlineArguments: o.parsedOperation.IsPersistedOperation && !o.kit.inlineArgumentsIncludePersisted,
+ }
+}
+
+func (o *OperationKit) collectInlineArguments(result *astnormalization.NormalizationResult) {
+ if result == nil || len(result.InlineArguments) == 0 {
+ return
+ }
+ o.parsedOperation.InlineArguments = slices.Clone(result.InlineArguments)
+}
+
// NormalizeOperation normalizes the operation. After normalization the normalized representation of the operation
// and variables is available. Also, the final operation ID is generated.
func (o *OperationKit) NormalizeOperation(clientName string, isApq bool) (bool, error) {
@@ -743,12 +764,13 @@ func (o *OperationKit) normalizePersistedOperation(clientName string, isApq bool
report := &operationreport.Report{}
o.kit.doc.Input.Variables = o.parsedOperation.Request.Variables
- o.kit.staticNormalizer.NormalizeNamedOperation(o.kit.doc, o.operationProcessor.executor.ClientSchema, staticOperationName, report)
+ inlineArgsResult := o.kit.staticNormalizer.NormalizeNamedOperationWithResult(o.kit.doc, o.operationProcessor.executor.ClientSchema, staticOperationName, report, o.inlineArgumentsRunOptions())
if report.HasErrors() {
return false, &reportError{
report: report,
}
}
+ o.collectInlineArguments(inlineArgsResult)
// Print the operation with the original operation name
o.kit.doc.OperationDefinitions[o.operationDefinitionRef].Name = o.originalOperationNameRef
@@ -772,6 +794,7 @@ type NormalizationCacheEntry struct {
normalizedRepresentation string
operationType string
operationDefinitionRef int
+ inlineArguments []astnormalization.InlineArgument
removedSkipIncludeVariableNames []string
}
@@ -824,6 +847,7 @@ func (o *OperationKit) normalizeNonPersistedOperation() (cached bool, err error)
o.parsedOperation.NormalizedRepresentation = entry.normalizedRepresentation
o.parsedOperation.Type = entry.operationType
o.parsedOperation.NormalizationCacheHit = true
+ o.parsedOperation.InlineArguments = entry.inlineArguments
// Variables are not cached, so the skip/include variables that normalization
// removed have to be stripped from the request variables on every hit. Dual-use
@@ -843,12 +867,13 @@ func (o *OperationKit) normalizeNonPersistedOperation() (cached bool, err error)
// normalize the operation
report := &operationreport.Report{}
o.kit.doc.Input.Variables = o.parsedOperation.Request.Variables
- o.kit.staticNormalizer.NormalizeNamedOperation(o.kit.doc, o.operationProcessor.executor.ClientSchema, staticOperationName, report)
+ inlineArgsResult := o.kit.staticNormalizer.NormalizeNamedOperationWithResult(o.kit.doc, o.operationProcessor.executor.ClientSchema, staticOperationName, report, o.inlineArgumentsRunOptions())
if report.HasErrors() {
return false, &reportError{
report: report,
}
}
+ o.collectInlineArguments(inlineArgsResult)
// Normalization removed skip/include variables from the operation and variables. For example,
//
@@ -893,6 +918,7 @@ func (o *OperationKit) normalizeNonPersistedOperation() (cached bool, err error)
entry := NormalizationCacheEntry{
normalizedRepresentation: o.parsedOperation.NormalizedRepresentation,
operationType: o.parsedOperation.Type,
+ inlineArguments: o.parsedOperation.InlineArguments,
removedSkipIncludeVariableNames: removedSkipIncludeVariableNames,
}
o.cache.normalizationCache.Set(cacheKey, entry, 1)
@@ -1172,6 +1198,7 @@ func (o *OperationKit) handleFoundPersistedOperationEntry(entry NormalizationCac
o.parsedOperation.NormalizationCacheHit = true
o.parsedOperation.NormalizedRepresentation = entry.normalizedRepresentation
o.parsedOperation.Type = entry.operationType
+ o.parsedOperation.InlineArguments = entry.inlineArguments
// We will always only have a single operation definition in the document
// Because we removed the unused operations during normalization
o.operationDefinitionRef = 0
@@ -1226,6 +1253,7 @@ func (o *OperationKit) savePersistedOperationToCache(clientName string, isApq bo
normalizedRepresentation: o.parsedOperation.NormalizedRepresentation,
operationType: o.parsedOperation.Type,
operationDefinitionRef: o.operationDefinitionRef,
+ inlineArguments: o.parsedOperation.InlineArguments,
}
if isApq {
@@ -1536,16 +1564,19 @@ type parseKitOptions struct {
disableExposingVariablesContentOnValidationError bool
relaxSubgraphOperationFieldSelectionMergingNullability bool
enableDefer bool
+ validateInlineArguments config.ValidateInlineArguments
}
func createParseKit(i int, options *parseKitOptions) *parseKit {
+ normalizationOptions := buildNormalizationOptions(options.enableDefer, options.validateInlineArguments)
+
return &parseKit{
i: i,
parser: astparser.NewParser(),
doc: ast.NewSmallDocument(),
keyGen: xxhash.New(),
sha256Hash: sha256.New(),
- staticNormalizer: astnormalization.NewWithOpts(buildNormalizationOptions(options.enableDefer)...),
+ staticNormalizer: astnormalization.NewWithOpts(normalizationOptions...),
variablesNormalizer: astnormalization.NewVariablesNormalizer(),
variablesRemapper: astnormalization.NewVariablesMapper(),
printer: &astprinter.Printer{},
@@ -1560,11 +1591,12 @@ func createParseKit(i int, options *parseKitOptions) *parseKit {
},
DisableExposingVariablesContent: options.disableExposingVariablesContentOnValidationError,
}),
- operationValidator: createOperationValidator(options),
+ inlineArgumentsIncludePersisted: options.validateInlineArguments.IncludePersistedOperations,
+ operationValidator: createOperationValidator(options),
}
}
-func buildNormalizationOptions(enableDefer bool) []astnormalization.Option {
+func buildNormalizationOptions(enableDefer bool, validateInlineArguments config.ValidateInlineArguments) []astnormalization.Option {
opts := []astnormalization.Option{
astnormalization.WithRemoveNotMatchingOperationDefinitions(),
astnormalization.WithInlineFragmentSpreads(),
@@ -1573,13 +1605,26 @@ func buildNormalizationOptions(enableDefer bool) []astnormalization.Option {
}
if enableDefer {
- opts = append(opts, astnormalization.WithEnableDefer(),
+ opts = append(opts,
+ astnormalization.WithEnableDefer(),
astnormalization.WithPrevalidationRules(
astvalidation.DeferStreamOnValidOperations(),
astvalidation.DeferStreamHaveUniqueLabels(),
astvalidation.DirectivesAreInValidLocations(),
- astvalidation.StreamAppliedToListFieldsOnly()))
+ astvalidation.StreamAppliedToListFieldsOnly(),
+ ),
+ )
}
+
+ if validateInlineArguments.Enabled() {
+ opts = append(opts, astnormalization.WithInlineArgumentsValidation(astnormalization.InlineArgumentsValidationOptions{
+ Enforce: validateInlineArguments.Enforcing(),
+ ErrorMessage: validateInlineArguments.ErrorMessage,
+ ErrorCode: validateInlineArguments.ErrorCode,
+ StatusCode: validateInlineArguments.EnforceHTTPStatusCode,
+ }))
+ }
+
return opts
}
@@ -1617,6 +1662,7 @@ func NewOperationProcessor(opts OperationProcessorOptions) *OperationProcessor {
apolloRouterCompatibilityFlags: opts.ApolloRouterCompatibilityFlags,
disableExposingVariablesContentOnValidationError: opts.DisableExposingVariablesContentOnValidationError,
relaxSubgraphOperationFieldSelectionMergingNullability: opts.RelaxSubgraphOperationFieldSelectionMergingNullability,
+ validateInlineArguments: opts.ValidateInlineArguments,
},
}
for i := 0; i < opts.ParseKitPoolSize; i++ {
diff --git a/router/core/websocket.go b/router/core/websocket.go
index a68a403a5f..3fe6ec3345 100644
--- a/router/core/websocket.go
+++ b/router/core/websocket.go
@@ -1000,6 +1000,11 @@ func (h *WebSocketConnectionHandler) parseAndPlan(registration *SubscriptionRegi
}
opContext.variablesNormalizationCacheHit = cached
+ logInlineArguments(h.logger, operationKit.parsedOperation)
+ if h.operationProcessor.parseKitOptions.validateInlineArguments.ReturnInResponseExtensions {
+ opContext.inlineArguments = inlineArgumentQualifiedNames(operationKit.parsedOperation)
+ }
+
cached, err = operationKit.RemapVariables(h.disableVariablesRemapping)
if err != nil {
opContext.normalizationTime = time.Since(startNormalization)
@@ -1130,6 +1135,7 @@ func (h *WebSocketConnectionHandler) executeSubscription(registration *Subscript
resolveCtx.RenameTypeNames = h.graphqlHandler.executor.RenameTypeNames
resolveCtx.TracingOptions = operationCtx.traceOptions
resolveCtx.Extensions = operationCtx.extensions
+ resolveCtx.InlineArguments = operationCtx.inlineArguments
resolveCtx.ExecutionOptions = operationCtx.executionOptions
if operationCtx.initialPayload != nil {
diff --git a/router/go.mod b/router/go.mod
index 72f3a28c28..417b19f4e5 100644
--- a/router/go.mod
+++ b/router/go.mod
@@ -3,7 +3,7 @@ module github.com/wundergraph/cosmo/router
go 1.25.0
require (
- connectrpc.com/connect v1.16.2
+ connectrpc.com/connect v1.19.2
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/buger/jsonparser v1.1.2
github.com/cespare/xxhash/v2 v2.3.0
@@ -31,7 +31,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/twmb/franz-go v1.16.1
- github.com/wundergraph/graphql-go-tools/v2 v2.10.0
+ github.com/wundergraph/graphql-go-tools/v2 v2.12.1
// Do not upgrade, it renames attributes we rely on
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/contrib/propagators/b3 v1.44.0
diff --git a/router/go.sum b/router/go.sum
index 411805cf07..f435287c7e 100644
--- a/router/go.sum
+++ b/router/go.sum
@@ -1,5 +1,5 @@
-connectrpc.com/connect v1.16.2 h1:ybd6y+ls7GOlb7Bh5C8+ghA6SvCBajHwxssO2CGFjqE=
-connectrpc.com/connect v1.16.2/go.mod h1:n2kgwskMHXC+lVqb18wngEpF95ldBHXjZYJussz5FRc=
+connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
+connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
connectrpc.com/vanguard v0.3.0 h1:prUKFm8rYDwvpvnOSoqdUowPMK0tRA0pbSrQoMd6Zng=
connectrpc.com/vanguard v0.3.0/go.mod h1:nxQ7+N6qhBiQczqGwdTw4oCqx1rDryIt20cEdECqToM=
github.com/99designs/gqlgen v0.17.76 h1:YsJBcfACWmXWU2t1yCjoGdOmqcTfOFpjbLAE443fmYI=
@@ -333,8 +333,8 @@ github.com/wundergraph/astjson v1.1.0 h1:xORDosrZ87zQFJwNGe/HIHXqzpdHOFmqWgykCLV
github.com/wundergraph/astjson v1.1.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw=
github.com/wundergraph/go-arena v1.3.0 h1:n0ng5a1vbd8YGq1u3rMr0vPU5f6AZ1BXIiUhL1UIok8=
github.com/wundergraph/go-arena v1.3.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw=
-github.com/wundergraph/graphql-go-tools/v2 v2.10.0 h1:hAzNsXbzbSTOeD3VcRFeHphLj5S0z5j7F2VaZbtkygs=
-github.com/wundergraph/graphql-go-tools/v2 v2.10.0/go.mod h1:rGG9m74sUyucfvSZ83Mjuq/6qRJetl1CVP872f/dCok=
+github.com/wundergraph/graphql-go-tools/v2 v2.12.1 h1:wds1aBlnml86PFhgK21sOqzJOb9eOfVF3mK8NLDxEVY=
+github.com/wundergraph/graphql-go-tools/v2 v2.12.1/go.mod h1:zREIKLmpjfNcGSubndaW/913r0Y8XbbYOXQeZFkwHdo=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go
index 8274c8258e..b14a90a693 100644
--- a/router/pkg/config/config.go
+++ b/router/pkg/config/config.go
@@ -497,6 +497,8 @@ type EngineExecutionConfiguration struct {
ValidateRequiredExternalFields bool `envDefault:"false" env:"ENGINE_VALIDATE_REQUIRED_EXTERNAL_FIELDS" yaml:"validate_required_external_fields"`
RelaxSubgraphOperationFieldSelectionMergingNullability bool `envDefault:"false" env:"ENGINE_RELAX_SUBGRAPH_OPERATION_FIELD_SELECTION_MERGING_NULLABILITY" yaml:"relax_subgraph_operation_field_selection_merging_nullability"`
+
+ ValidateInlineArguments ValidateInlineArguments `yaml:"validate_inline_arguments" envPrefix:"ENGINE_VALIDATE_INLINE_ARGUMENTS_"`
}
type BlockOperationConfiguration struct {
@@ -594,6 +596,33 @@ type CostControl struct {
IgnoreImplementingTypeWeights bool `yaml:"ignore_implementing_type_weights,omitempty" envDefault:"false" env:"IGNORE_IMPLEMENTING_TYPE_WEIGHTS"`
}
+type EnforcementMode string
+
+const (
+ EnforcementModeOff EnforcementMode = "off"
+ EnforcementModePermissive EnforcementMode = "permissive"
+ EnforcementModeStrict EnforcementMode = "strict"
+)
+
+type ValidateInlineArguments struct {
+ Mode EnforcementMode `yaml:"mode,omitempty" envDefault:"off" env:"MODE"`
+ EnforceHTTPStatusCode int `yaml:"enforce_http_status_code,omitempty" envDefault:"400" env:"ENFORCE_HTTP_STATUS_CODE"`
+ ErrorCode string `yaml:"error_code,omitempty" envDefault:"INLINE_ARGUMENT_VALUES_NOT_ALLOWED" env:"ERROR_CODE"`
+ ErrorMessage string `yaml:"error_message,omitempty" envDefault:"Inline argument values are not allowed. Use variables instead." env:"ERROR_MESSAGE"`
+ IncludePersistedOperations bool `yaml:"include_persisted_operations,omitempty" envDefault:"false" env:"INCLUDE_PERSISTED_OPERATIONS"`
+ ReturnInResponseExtensions bool `yaml:"return_in_response_extensions,omitempty" envDefault:"false" env:"RETURN_IN_RESPONSE_EXTENSIONS"`
+}
+
+// Enabled reports whether the policy is active in any mode.
+func (d ValidateInlineArguments) Enabled() bool {
+ return d.Mode == EnforcementModePermissive || d.Mode == EnforcementModeStrict
+}
+
+// Enforcing reports whether the policy rejects offending operations.
+func (d ValidateInlineArguments) Enforcing() bool {
+ return d.Mode == EnforcementModeStrict
+}
+
type ComplexityLimit struct {
Enabled bool `yaml:"enabled" envDefault:"false"`
Limit int `yaml:"limit,omitempty" envDefault:"0"`
diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json
index 452829c884..3925f74e5d 100644
--- a/router/pkg/config/config.schema.json
+++ b/router/pkg/config/config.schema.json
@@ -3652,6 +3652,46 @@
"description": "The configuration for the engine. The engine is used to execute the GraphQL queries, mutations and subscriptions. Only modify this if you know what you are doing.",
"additionalProperties": false,
"properties": {
+ "validate_inline_arguments": {
+ "type": "object",
+ "description": "Detects, and optionally rejects, operations that carry hardcoded inline argument values instead of variables.",
+ "additionalProperties": false,
+ "properties": {
+ "mode": {
+ "type": "string",
+ "enum": ["off", "permissive", "strict"],
+ "default": "off",
+ "description": "Controls the policy: 'off' disables it; 'permissive' detects and records inline arguments but still executes the operation; 'strict' rejects operations that use inline argument values."
+ },
+ "enforce_http_status_code": {
+ "type": "integer",
+ "minimum": 100,
+ "maximum": 599,
+ "default": 400,
+ "description": "HTTP status code returned when an operation is rejected in enforce mode."
+ },
+ "error_code": {
+ "type": "string",
+ "default": "INLINE_ARGUMENT_VALUES_NOT_ALLOWED",
+ "description": "The extensions.code emitted on rejection."
+ },
+ "error_message": {
+ "type": "string",
+ "default": "Inline argument values are not allowed. Use variables instead.",
+ "description": "The human-readable error/hint message surfaced to the client."
+ },
+ "include_persisted_operations": {
+ "type": "boolean",
+ "default": false,
+ "description": "When true, applies the policy to persisted operations as well. Persisted operations are exempt by default."
+ },
+ "return_in_response_extensions": {
+ "type": "boolean",
+ "default": false,
+ "description": "When true, reports detected inline arguments back to the client under extensions.inlineArguments of the GraphQL response. Applies only in non-enforcing mode."
+ }
+ }
+ },
"debug": {
"type": "object",
"description": "The debug configuration. The debug configuration is used to enable the debug mode for the engine.",
diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json
index 006d3a23e5..bb552f7299 100644
--- a/router/pkg/config/testdata/config_defaults.json
+++ b/router/pkg/config/testdata/config_defaults.json
@@ -500,7 +500,15 @@
"WebSocketClientPingTimeout": 30000000000,
"WebSocketClientAckTimeout": 30000000000,
"ValidateRequiredExternalFields": false,
- "RelaxSubgraphOperationFieldSelectionMergingNullability": false
+ "RelaxSubgraphOperationFieldSelectionMergingNullability": false,
+ "ValidateInlineArguments": {
+ "Mode": "off",
+ "EnforceHTTPStatusCode": 400,
+ "ErrorCode": "INLINE_ARGUMENT_VALUES_NOT_ALLOWED",
+ "ErrorMessage": "Inline argument values are not allowed. Use variables instead.",
+ "IncludePersistedOperations": false,
+ "ReturnInResponseExtensions": false
+ }
},
"WebSocket": {
"Enabled": true,
diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json
index 04ad7fb5ba..afde23fa64 100644
--- a/router/pkg/config/testdata/config_full.json
+++ b/router/pkg/config/testdata/config_full.json
@@ -952,7 +952,15 @@
"WebSocketClientPingTimeout": 30000000000,
"WebSocketClientAckTimeout": 30000000000,
"ValidateRequiredExternalFields": false,
- "RelaxSubgraphOperationFieldSelectionMergingNullability": false
+ "RelaxSubgraphOperationFieldSelectionMergingNullability": false,
+ "ValidateInlineArguments": {
+ "Mode": "off",
+ "EnforceHTTPStatusCode": 400,
+ "ErrorCode": "INLINE_ARGUMENT_VALUES_NOT_ALLOWED",
+ "ErrorMessage": "Inline argument values are not allowed. Use variables instead.",
+ "IncludePersistedOperations": false,
+ "ReturnInResponseExtensions": false
+ }
},
"WebSocket": {
"Enabled": true,