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,