Skip to content

fix(router): enable safelisting on websocket subscriptions#2222

Merged
dkorittki merged 16 commits intomainfrom
dominik/eng-7506-subscription-registration-with-persisted-operation
Sep 26, 2025
Merged

fix(router): enable safelisting on websocket subscriptions#2222
dkorittki merged 16 commits intomainfrom
dominik/eng-7506-subscription-registration-with-persisted-operation

Conversation

@dkorittki
Copy link
Copy Markdown
Contributor

@dkorittki dkorittki commented Sep 16, 2025

Summary by CodeRabbit

  • New Features

    • Persisted operations over WebSocket: pre-parse SHA-256 computation and validation, safelist enforcement, optional logging of unknown persisted operations, rejection of mismatched hashes, cache-poisoning prevention, and client-name propagation.
  • Tests

    • Expanded WebSocket tests for safelist/unknown-hash scenarios, logging observation, mismatched-hash rejection, cache-poisoning protection, and client-name propagation.
    • Added subscription test data for end-to-end validation.

Checklist

Motivation and Context

Users are confused that safelisting does not work for subscriptions. After some digging I found that it does work but only on HTTP2 streams, not websocket connections. This is because for websockets, we haven't implemented safelisting, although persisted operations are in fact supported. This PR adds safelisting in a similar fashion to how its done for HTTP connections in prehandler, including hash calculation.

Now, when safelisting is enabled and you provide a query whoms hash is unknown, you get this error:

Query with payload whoms hash is unknown

{
  "id": "sub-1",
  "type": "subscribe",
  "payload": {
    "query": "subscription { currentTime { unixTime timeStamp } }"
  }
}

Query which references the unknown hash directly

{
  "id": "sub-1",
  "type": "subscribe",
  "payload": {
    "extensions": {
      "persistedQuery": {
        "version": 1,
        "sha256Hash": "9a41d21da2823195ad42c11d51e9ad3345824abdabf567b3615a235843a1fcc7"
      }
    }
  }
}

It would also be the same for an operation containing both the query body and hash extension.

Feedback on websocket:

[{"message":"operation '9a41d21da2823195ad42c11d51e9ad3345824abdabf567b3615a235843a1fcc7' for client 'my-client' not found"}]

Note: The subscription is still registered, even if we faced this error. That's due to the error handling of websocket parsing. Not sure if thats a good or bad thing tbh.

I also added support for persisted_operations.log_unknown in a similar fashion to the HTTP prehandler: log it bu if safelisting is disabled let it pass.

For safelisting to work I had to add hash calculation in front of it, similar to how its done in the HTTP prehandler.
Alongside that I added a verification of provided hash vs calculated hash. Thats actually kind of a breaking change.
No matter the safelist settings, these queries will now get blocked (look at sha256Hash). Before this PR it would have passed.

curl -X POST http://localhost:3002/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "subscription { currentTime { unixTime timeStamp } }",
    "extensions": {
      "persistedQuery": {
        "version": 1,
        "sha256Hash": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
      }
    }
  }'

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Sep 16, 2025

Walkthrough

Computes operation SHA‑256 pre-parse when configured, enforces persistedQuery Sha256Hash matches query body, conditionally populates persisted-query extensions, expands persisted-operation fetch conditions, logs or rejects unknown persisted operations per config, and adds tests plus persisted-operation test data.

Changes

Cohort / File(s) Summary of Changes
WebSocket core logic
router/core/websocket.go
Adds shouldComputeOperationSha256 method and pre-parse SHA‑256 computation; enforces persistedQuery Sha256Hash vs query body; populates GraphQLRequestExtensions.PersistedQuery when safelist/log-unknown enabled; broadens persisted-operation fetch condition; logs or returns errors for missing persisted operations; adds persistedoperation import.
WebSocket persisted-operation tests
router-tests/websocket_test.go
Adds multiple TestWebSockets subtests covering safelist and log-unknown flows: unknown/known persisted operations, matching/mismatched hashes, cache-poison prevention, client-name propagation; configures RouterOptions.WithPersistedOperationsConfig and uses log observation; adds zapcore import.
Persisted-operation test data
router-tests/testenv/testdata/cdn/organization/graph/operations/my-client/6e94d99132b54...36628c.json
Adds a versioned persisted-operation JSON file containing a subscription body used by new tests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title is concise and accurately describes the primary change: enabling safelisting for websocket subscriptions in the router. It is a single clear sentence that matches the PR objectives and the changes in the code and tests.
✨ Finishing touches
  • 📝 Generate Docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Sep 16, 2025

Router image scan passed

✅ No security vulnerabilities found in image:

ghcr.io/wundergraph/cosmo/router:sha-c0fddc1f4814f132ee9316075bda3fc95a29e8ee

@dkorittki dkorittki marked this pull request as ready for review September 17, 2025 16:20
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
router-tests/websocket_test.go (2)

2073-2236: Great coverage of persisted-operation paths over WebSockets. Add two cases to harden hash+query behavior when safelist/log_unknown are enabled.

  • Add tests for:
    • Provided hash + query + safelist enabled + mismatch → error.
    • Provided hash + query + safelist enabled + match → success.
      This guards against regressions when both fields are present under enforcement modes.

I can draft these two subtests mirroring the existing patterns—want me to push patches?


2136-2169: Avoid magic SHA-256 constants in assertions; compute at runtime for stability.

Precompute the expected hash from the query to reduce brittleness if whitespace or formatting changes.

Apply this diff within the test (add hex import at top if needed):

@@
-			require.Len(t, logEntries, 1)
-			requestContext := logEntries[0].ContextMap()
-			require.Contains(t, requestContext["query"], "subscription { currentTime { unixTime } }")
-			require.Equal(t, "6e94d99132b544a0d7522696a7d35643d56a26c7b8c2e0df29e2b9935636628c", requestContext["sha256Hash"])
+			require.Len(t, logEntries, 1)
+			requestContext := logEntries[0].ContextMap()
+			query := "subscription { currentTime { unixTime } }"
+			require.Contains(t, requestContext["query"], query)
+			sum := sha256.Sum256([]byte(query))
+			expected := fmt.Sprintf("%x", sum[:])
+			require.Equal(t, expected, requestContext["sha256Hash"])
router/core/websocket.go (2)

864-873: Log more context on unknown persisted operations (client name) and avoid nil/empty query logs.

Add client name to the warning and guard empty queries to reduce noisy entries.

Apply this diff:

-				h.logger.Warn("Unknown persisted operation found", zap.String("query", operationKit.parsedOperation.Request.Query), zap.String("sha256Hash", poNotFoundErr.Sha256Hash))
+				fields := []zap.Field{zap.String("sha256Hash", poNotFoundErr.Sha256Hash), zap.String("client_name", h.clientInfo.Name)}
+				if q := operationKit.parsedOperation.Request.Query; q != "" {
+					fields = append(fields, zap.String("query", q))
+				}
+				h.logger.Warn("Unknown persisted operation found", fields...)

1245-1257: Nil-safety in shouldComputeOperationSha256.

Accessing PersistedQuery.HasHash() assumes a non-nil receiver. Prefer explicit nil check.

Apply this diff:

-func (h *WebSocketConnectionHandler) shouldComputeOperationSha256(operationKit *OperationKit) bool {
-	hasPersistedHash := operationKit.parsedOperation.GraphQLRequestExtensions.PersistedQuery.HasHash()
+func (h *WebSocketConnectionHandler) shouldComputeOperationSha256(operationKit *OperationKit) bool {
+	pq := operationKit.parsedOperation.GraphQLRequestExtensions.PersistedQuery
+	hasPersistedHash := pq != nil && pq.Sha256Hash != ""
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 29c7b00 and 157b614.

📒 Files selected for processing (3)
  • router-tests/testenv/testdata/cdn/organization/graph/operations/my-client/8ad544bda5b2ad7a59481e31fb6fa62705fd072b20fdaadba4f3908d01f2c132.json (1 hunks)
  • router-tests/websocket_test.go (2 hunks)
  • router/core/websocket.go (3 hunks)
🧰 Additional context used
🧠 Learnings (5)
📓 Common learnings
Learnt from: endigma
PR: wundergraph/cosmo#2141
File: router-tests/http_subscriptions_test.go:17-55
Timestamp: 2025-08-28T09:17:49.477Z
Learning: The Cosmo router uses a custom, intentionally rigid multipart implementation for GraphQL subscriptions. The multipart parsing in test files should remain strict and not be made more tolerant, as this rigidity is by design.
📚 Learning: 2025-08-28T09:17:49.477Z
Learnt from: endigma
PR: wundergraph/cosmo#2141
File: router-tests/http_subscriptions_test.go:17-55
Timestamp: 2025-08-28T09:17:49.477Z
Learning: The Cosmo router uses a custom, intentionally rigid multipart implementation for GraphQL subscriptions. The multipart parsing in test files should remain strict and not be made more tolerant, as this rigidity is by design.

Applied to files:

  • router-tests/testenv/testdata/cdn/organization/graph/operations/my-client/8ad544bda5b2ad7a59481e31fb6fa62705fd072b20fdaadba4f3908d01f2c132.json
📚 Learning: 2025-08-28T09:18:10.121Z
Learnt from: endigma
PR: wundergraph/cosmo#2141
File: router-tests/http_subscriptions_test.go:100-108
Timestamp: 2025-08-28T09:18:10.121Z
Learning: In router-tests/http_subscriptions_test.go heartbeat tests, the message ordering should remain strict with data messages followed by heartbeat messages, as the timing is deterministic and known by design in the Cosmo router implementation.

Applied to files:

  • router-tests/websocket_test.go
📚 Learning: 2025-09-02T12:52:27.677Z
Learnt from: endigma
PR: wundergraph/cosmo#2181
File: router/core/operation_processor.go:0-0
Timestamp: 2025-09-02T12:52:27.677Z
Learning: Hash validation for persisted queries with query bodies is performed in router/core/graphql_prehandler.go in the handleOperation function, not in the APQ processing logic in operation_processor.go. This validates that extensions.persistedQuery.sha256Hash matches the query body before any APQ operations occur.

Applied to files:

  • router/core/websocket.go
📚 Learning: 2025-09-02T12:52:27.677Z
Learnt from: endigma
PR: wundergraph/cosmo#2181
File: router/core/operation_processor.go:0-0
Timestamp: 2025-09-02T12:52:27.677Z
Learning: Hash validation for persisted queries with query bodies is performed in router/core/graphql_prehandler.go in the handleOperation function at lines 571-578, not in the APQ processing logic in operation_processor.go. This validates that extensions.persistedQuery.sha256Hash matches the computed query hash before any APQ operations occur. There's also a test case that verifies this behavior.

Applied to files:

  • router/core/websocket.go
🧬 Code graph analysis (2)
router-tests/websocket_test.go (3)
router-tests/testenv/testenv.go (7)
  • Run (107-124)
  • Config (286-342)
  • Environment (1729-1765)
  • WSWriteJSON (2785-2826)
  • WebSocketMessage (2281-2285)
  • WSReadJSON (2699-2740)
  • LogObservationConfig (388-391)
router/pkg/config/config.go (3)
  • Config (987-1061)
  • PersistedOperationsConfig (842-848)
  • SafelistConfiguration (850-852)
router/core/router.go (2)
  • Option (172-172)
  • WithPersistedOperationsConfig (2044-2048)
router/core/websocket.go (2)
router/core/operation_processor.go (3)
  • GraphQLRequestExtensions (188-190)
  • GraphQLRequestExtensionsPersistedQuery (192-195)
  • OperationKit (171-179)
router/internal/persistedoperation/client.go (1)
  • PersistentOperationNotFoundError (18-21)
🔇 Additional comments (3)
router-tests/testenv/testdata/cdn/organization/graph/operations/my-client/8ad544bda5b2ad7a59481e31fb6fa62705fd072b20fdaadba4f3908d01f2c132.json (1)

1-4: Fixture LGTM.

Hash and body align with subscription used in tests.

router-tests/websocket_test.go (1)

20-20: Import add is appropriate.

zapcore is used for log observation level; no issues.

router/core/websocket.go (1)

32-33: New import is correct.

Used for typed error matching of unknown persisted operation.

Comment thread router/core/websocket.go
Comment thread router/core/websocket.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (4)
router-tests/websocket_test.go (2)

2103-2130: Tidy up the subscription lifecycle in the test

After asserting the first "next", send a "complete" and assert the "complete" frame (consistent with other tests) instead of closing the socket immediately.

Apply:

-			require.NoError(t, conn.Close())
+			require.NoError(t, testenv.WSWriteJSON(t, conn, &testenv.WebSocketMessage{ID: "1", Type: "complete"}))
+			var complete testenv.WebSocketMessage
+			require.NoError(t, testenv.WSReadJSON(t, conn, &complete))
+			require.Equal(t, "complete", complete.Type)
+			require.NoError(t, conn.Close())

2132-2171: Strengthen the log assertion with client name

Since the server includes client scoping in persisted-op lookups, assert the client as well once logged. Recommend logging clientName from the error and asserting it here.

Test change (after server change below):

-			require.Len(t, logEntries, 1)
+			require.Len(t, logEntries, 1)
 			requestContext := logEntries[0].ContextMap()
 			require.Contains(t, requestContext["query"], "subscription { currentTime { unixTime } }")
 			require.Equal(t, "6e94d99132b544a0d7522696a7d35643d56a26c7b8c2e0df29e2b9935636628c", requestContext["sha256Hash"])
+			require.Equal(t, "my-client", requestContext["clientName"])
router/core/websocket.go (2)

840-860: Don’t overwrite client-provided persistedQuery after validation

You correctly validate before mutation—nice. However, you still overwrite PersistedQuery even when the client provided one. Preserve client-provided fields (e.g., version) and only synthesize when missing.

Apply:

-		if h.operationBlocker.safelistEnabled || h.operationBlocker.logUnknownOperationsEnabled {
-			// Set the request hash to the parsed hash, to see if it matches a persisted operation
-			operationKit.parsedOperation.GraphQLRequestExtensions.PersistedQuery = &GraphQLRequestExtensionsPersistedQuery{
-				Sha256Hash: operationKit.parsedOperation.Sha256Hash,
-			}
-		}
+		if (h.operationBlocker.safelistEnabled || h.operationBlocker.logUnknownOperationsEnabled) &&
+			!operationKit.parsedOperation.GraphQLRequestExtensions.PersistedQuery.HasHash() {
+			// Synthesize only when missing
+			operationKit.parsedOperation.GraphQLRequestExtensions.PersistedQuery = &GraphQLRequestExtensionsPersistedQuery{
+				Sha256Hash: operationKit.parsedOperation.Sha256Hash,
+			}
+		}

861-874: Include clientName in “unknown persisted operation” log

This aids triage across multi-tenant clients and matches the persisted-op error context.

Apply:

-				h.logger.Warn("Unknown persisted operation found", zap.String("query", operationKit.parsedOperation.Request.Query), zap.String("sha256Hash", poNotFoundErr.Sha256Hash))
+				h.logger.Warn("Unknown persisted operation found",
+					zap.String("query", operationKit.parsedOperation.Request.Query),
+					zap.String("sha256Hash", poNotFoundErr.Sha256Hash),
+					zap.String("clientName", poNotFoundErr.ClientName))
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 157b614 and 3148a8b.

📒 Files selected for processing (2)
  • router-tests/websocket_test.go (2 hunks)
  • router/core/websocket.go (3 hunks)
🧰 Additional context used
🧠 Learnings (4)
📓 Common learnings
Learnt from: endigma
PR: wundergraph/cosmo#2141
File: router-tests/http_subscriptions_test.go:17-55
Timestamp: 2025-08-28T09:17:49.477Z
Learning: The Cosmo router uses a custom, intentionally rigid multipart implementation for GraphQL subscriptions. The multipart parsing in test files should remain strict and not be made more tolerant, as this rigidity is by design.
Learnt from: endigma
PR: wundergraph/cosmo#2181
File: router/core/operation_processor.go:0-0
Timestamp: 2025-09-02T12:52:27.677Z
Learning: Hash validation for persisted queries with query bodies is performed in router/core/graphql_prehandler.go in the handleOperation function at lines 571-578, not in the APQ processing logic in operation_processor.go. This validates that extensions.persistedQuery.sha256Hash matches the computed query hash before any APQ operations occur. There's also a test case that verifies this behavior.
📚 Learning: 2025-08-28T09:18:10.121Z
Learnt from: endigma
PR: wundergraph/cosmo#2141
File: router-tests/http_subscriptions_test.go:100-108
Timestamp: 2025-08-28T09:18:10.121Z
Learning: In router-tests/http_subscriptions_test.go heartbeat tests, the message ordering should remain strict with data messages followed by heartbeat messages, as the timing is deterministic and known by design in the Cosmo router implementation.

Applied to files:

  • router-tests/websocket_test.go
📚 Learning: 2025-09-02T12:52:27.677Z
Learnt from: endigma
PR: wundergraph/cosmo#2181
File: router/core/operation_processor.go:0-0
Timestamp: 2025-09-02T12:52:27.677Z
Learning: Hash validation for persisted queries with query bodies is performed in router/core/graphql_prehandler.go in the handleOperation function, not in the APQ processing logic in operation_processor.go. This validates that extensions.persistedQuery.sha256Hash matches the query body before any APQ operations occur.

Applied to files:

  • router/core/websocket.go
📚 Learning: 2025-09-02T12:52:27.677Z
Learnt from: endigma
PR: wundergraph/cosmo#2181
File: router/core/operation_processor.go:0-0
Timestamp: 2025-09-02T12:52:27.677Z
Learning: Hash validation for persisted queries with query bodies is performed in router/core/graphql_prehandler.go in the handleOperation function at lines 571-578, not in the APQ processing logic in operation_processor.go. This validates that extensions.persistedQuery.sha256Hash matches the computed query hash before any APQ operations occur. There's also a test case that verifies this behavior.

Applied to files:

  • router/core/websocket.go
🧬 Code graph analysis (2)
router-tests/websocket_test.go (3)
router-tests/testenv/testenv.go (7)
  • Run (107-124)
  • Config (286-342)
  • Environment (1729-1765)
  • WSWriteJSON (2785-2826)
  • WebSocketMessage (2281-2285)
  • WSReadJSON (2699-2740)
  • LogObservationConfig (388-391)
router/pkg/config/config.go (3)
  • Config (987-1061)
  • PersistedOperationsConfig (842-848)
  • SafelistConfiguration (850-852)
router/core/router.go (2)
  • Option (172-172)
  • WithPersistedOperationsConfig (2044-2048)
router/core/websocket.go (2)
router/core/operation_processor.go (3)
  • GraphQLRequestExtensions (188-190)
  • GraphQLRequestExtensionsPersistedQuery (192-195)
  • OperationKit (171-179)
router/internal/persistedoperation/client.go (1)
  • PersistentOperationNotFoundError (18-21)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (12)
  • GitHub Check: build-router
  • GitHub Check: image_scan
  • GitHub Check: build_push_image
  • GitHub Check: build_push_image (nonroot)
  • GitHub Check: image_scan (nonroot)
  • GitHub Check: integration_test (./events)
  • GitHub Check: integration_test (./telemetry)
  • GitHub Check: integration_test (./. ./fuzzquery ./lifecycle ./modules)
  • GitHub Check: build_test
  • GitHub Check: Analyze (go)
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: build_test
🔇 Additional comments (6)
router-tests/websocket_test.go (4)

20-21: LGTM: import zapcore for log observation

Import is correct and used for LogObservation level in new tests.


2074-2101: Good coverage: rejection when safelist is enabled

Test asserts the precise hash and client scoping in the error payload; mirrors router behavior.


2173-2204: LGTM: matching hash with body

This validates the pre-parse hash check for WebSockets; aligns with prehandler semantics.


2206-2236: LGTM: mismatched hash is correctly blocked

Error message assertion via Contains makes the test resilient.

router/core/websocket.go (2)

32-33: LGTM: import persistedoperation error type

Needed for typed handling of not-found; correct package path.


1245-1257: LGTM: hash-computation gate mirrors HTTP prehandler intent

Conditions cover: (1) body+hash for validation and (2) no hash but safelist/logUnknown enabled for lookup. Good.

@dkorittki
Copy link
Copy Markdown
Contributor Author

@endigma can have a look again? implemented your suggestions

@endigma endigma self-requested a review September 24, 2025 09:04
Comment thread router-tests/websocket_test.go
Comment thread router-tests/websocket_test.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
router-tests/websocket_test.go (2)

2074-2101: Avoid hardcoding the expected SHA-256; compute it to prevent drift

Hardcoding the hash couples the test to query whitespace/formatting. Compute it from the query to keep the test resilient.

Apply this diff:

-			require.JSONEq(t, `[{"message":"operation '9a41d21da2823195ad42c11d51e9ad3345824abdabf567b3615a235843a1fcc7' for client 'my-client' not found"}]`,
-				string(res.Payload))
+			expectedHash := fmt.Sprintf("%x", sha256.Sum256([]byte(`subscription { employeeUpdated(employeeID: 1) { id } }`)))
+			require.JSONEq(t,
+				fmt.Sprintf(`[{"message":"operation '%s' for client 'my-client' not found"}]`, expectedHash),
+				string(res.Payload),
+			)

2132-2171: Make log assertions type-safe to avoid brittle interface comparisons

ContextMap values are interface-typed. Assert types before Contains/Equal to avoid surprises if encoders change.

Apply this diff:

-			requestContext := logEntries[0].ContextMap()
-			require.Contains(t, requestContext["query"], "subscription { currentTime { unixTime timeStamp } }")
-			require.Equal(t, "8ad544bda5b2ad7a59481e31fb6fa62705fd072b20fdaadba4f3908d01f2c132", requestContext["sha256Hash"])
+			requestContext := logEntries[0].ContextMap()
+			q, okQ := requestContext["query"].(string)
+			h, okH := requestContext["sha256Hash"].(string)
+			require.True(t, okQ && okH, "unexpected log field types for query/sha256Hash")
+			require.Contains(t, q, "subscription { currentTime { unixTime timeStamp } }")
+			require.Equal(t, "8ad544bda5b2ad7a59481e31fb6fa62705fd072b20fdaadba4f3908d01f2c132", h)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 38dff72 and 49fa02d.

📒 Files selected for processing (1)
  • router-tests/websocket_test.go (2 hunks)
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: endigma
PR: wundergraph/cosmo#2181
File: router/core/operation_processor.go:0-0
Timestamp: 2025-09-02T12:52:27.677Z
Learning: Hash validation for persisted queries with query bodies is performed in router/core/graphql_prehandler.go in the handleOperation function at lines 571-578, not in the APQ processing logic in operation_processor.go. This validates that extensions.persistedQuery.sha256Hash matches the computed query hash before any APQ operations occur. There's also a test case that verifies this behavior.
Learnt from: endigma
PR: wundergraph/cosmo#2181
File: router/core/operation_processor.go:0-0
Timestamp: 2025-09-02T12:52:27.677Z
Learning: Hash validation for persisted queries with query bodies is performed in router/core/graphql_prehandler.go in the handleOperation function, not in the APQ processing logic in operation_processor.go. This validates that extensions.persistedQuery.sha256Hash matches the query body before any APQ operations occur.
📚 Learning: 2025-08-28T09:18:10.121Z
Learnt from: endigma
PR: wundergraph/cosmo#2141
File: router-tests/http_subscriptions_test.go:100-108
Timestamp: 2025-08-28T09:18:10.121Z
Learning: In router-tests/http_subscriptions_test.go heartbeat tests, the message ordering should remain strict with data messages followed by heartbeat messages, as the timing is deterministic and known by design in the Cosmo router implementation.

Applied to files:

  • router-tests/websocket_test.go
🧬 Code graph analysis (1)
router-tests/websocket_test.go (2)
router-tests/testenv/testenv.go (7)
  • Run (107-124)
  • Config (286-342)
  • Environment (1729-1765)
  • WSWriteJSON (2785-2826)
  • WebSocketMessage (2281-2285)
  • WSReadJSON (2699-2740)
  • LogObservationConfig (388-391)
router/pkg/config/config.go (4)
  • Config (995-1069)
  • PersistedOperationsConfig (850-856)
  • SafelistConfiguration (858-860)
  • AutomaticPersistedQueriesConfig (862-866)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: build-router
  • GitHub Check: build_push_image
  • GitHub Check: image_scan (nonroot)
  • GitHub Check: build_push_image (nonroot)
  • GitHub Check: image_scan
  • GitHub Check: integration_test (./telemetry)
  • GitHub Check: integration_test (./events)
  • GitHub Check: integration_test (./. ./fuzzquery ./lifecycle ./modules)
  • GitHub Check: build_test
  • GitHub Check: build_test
  • GitHub Check: Analyze (go)
🔇 Additional comments (4)
router-tests/websocket_test.go (4)

20-21: Import looks correct

zapcore is used for LogObservation level; no issues.


2103-2130: Known-hash safelist pass case: LGTM

Covers the hash-only path with safelist enabled and uses the versioned persistedQuery extension as intended.


2173-2204: Matching hash enforcement: LGTM

Validates query+hash acceptance; aligns with the stricter pre-parse hash validation.


2206-2236: Mismatched hash rejection: LGTM

Good coverage for the breaking behavior: incorrect sha256Hash blocks the request even without safelisting.

Consider adding a companion test where query + hash match but the hash is not in the safelist (safelist disabled, log_unknown true) to assert it passes and logs. Want a patch for that?

Comment thread router-tests/websocket_test.go
@dkorittki dkorittki requested a review from endigma September 24, 2025 12:51
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (3)
router-tests/websocket_test.go (3)

2074-2101: Compute expected sha256 at test-time (avoid brittle hard-coding)

Derive the hash from the exact query string to reduce fragility if spacing changes.

Apply this diff:

-			conn := xEnv.InitGraphQLWebSocketConnection(nil, nil, []byte(`{"graphql-client-name": "my-client"}`))
-			err := testenv.WSWriteJSON(t, conn, testenv.WebSocketMessage{
+			conn := xEnv.InitGraphQLWebSocketConnection(nil, nil, []byte(`{"graphql-client-name": "my-client"}`))
+			query := "subscription { employeeUpdated(employeeID: 1) { id } }"
+			sum := sha256.Sum256([]byte(query))
+			expectedHash := fmt.Sprintf("%x", sum)
+			err := testenv.WSWriteJSON(t, conn, testenv.WebSocketMessage{
 				ID:      "1",
 				Type:    "subscribe",
-				Payload: []byte(`{"query":"subscription { employeeUpdated(employeeID: 1) { id } }"}`),
+				Payload: fmt.Appendf(nil, `{"query":"%s"}`, query),
 			})
 			require.NoError(t, err)
 			var res testenv.WebSocketMessage
 			err = testenv.WSReadJSON(t, conn, &res)
 			require.NoError(t, err)
 			require.Equal(t, "error", res.Type)
 			require.Equal(t, "1", res.ID)
-			require.JSONEq(t, `[{"message":"operation '9a41d21da2823195ad42c11d51e9ad3345824abdabf567b3615a235843a1fcc7' for client 'my-client' not found"}]`,
-				string(res.Payload))
+			require.JSONEq(t, fmt.Sprintf(`[{"message":"operation '%s' for client 'my-client' not found"}]`, expectedHash), string(res.Payload))

2132-2171: Derive logged sha256 dynamically instead of hard-coding

This avoids coupling to an exact whitespace format of the query.

Apply this diff:

-			conn := xEnv.InitGraphQLWebSocketConnection(nil, nil, []byte(`{"graphql-client-name": "my-client"}`))
-			err := testenv.WSWriteJSON(t, conn, testenv.WebSocketMessage{
+			conn := xEnv.InitGraphQLWebSocketConnection(nil, nil, []byte(`{"graphql-client-name": "my-client"}`))
+			query := "subscription { currentTime { unixTime timeStamp } }"
+			sum := sha256.Sum256([]byte(query))
+			expectedHash := fmt.Sprintf("%x", sum)
+			err := testenv.WSWriteJSON(t, conn, testenv.WebSocketMessage{
 				ID:      "1",
 				Type:    "subscribe",
-				Payload: []byte(`{"query":"subscription { currentTime { unixTime timeStamp } }"}`),
+				Payload: fmt.Appendf(nil, `{"query":"%s"}`, query),
 			})
 			require.NoError(t, err)
@@
-			require.Contains(t, string(res.Payload), `"data"`)
+			require.Contains(t, string(res.Payload), `"data"`)
@@
-			requestContext := logEntries[0].ContextMap()
-			require.Contains(t, requestContext["query"], "subscription { currentTime { unixTime timeStamp } }")
-			require.Equal(t, "8ad544bda5b2ad7a59481e31fb6fa62705fd072b20fdaadba4f3908d01f2c132", requestContext["sha256Hash"])
+			requestContext := logEntries[0].ContextMap()
+			require.Contains(t, requestContext["query"], query)
+			require.Equal(t, expectedHash, requestContext["sha256Hash"])

2173-2204: Compute matching hash from the query instead of hard-coding

Keeps the test resilient if the query body changes slightly.

Apply this diff:

-			err := testenv.WSWriteJSON(t, conn, testenv.WebSocketMessage{
+			query := "subscription { currentTime { unixTime } }"
+			sum := sha256.Sum256([]byte(query))
+			hash := fmt.Sprintf("%x", sum)
+			err := testenv.WSWriteJSON(t, conn, testenv.WebSocketMessage{
 				ID:   "1",
 				Type: "subscribe",
 				Payload: []byte(`{
-					"query": "subscription { currentTime { unixTime } }",
+					"query": "` + query + `",
 					"extensions": {
 						"persistedQuery": {
 							"version": 1,
-							"sha256Hash": "6e94d99132b544a0d7522696a7d35643d56a26c7b8c2e0df29e2b9935636628c"
+							"sha256Hash": "` + hash + `"
 						}
 					}
 				}`),
 			})
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 49fa02d and 19db833.

📒 Files selected for processing (1)
  • router-tests/websocket_test.go (2 hunks)
🧰 Additional context used
🧠 Learnings (7)
📓 Common learnings
Learnt from: endigma
PR: wundergraph/cosmo#2181
File: router/core/operation_processor.go:0-0
Timestamp: 2025-09-02T12:52:27.677Z
Learning: Hash validation for persisted queries with query bodies is performed in router/core/graphql_prehandler.go in the handleOperation function at lines 571-578, not in the APQ processing logic in operation_processor.go. This validates that extensions.persistedQuery.sha256Hash matches the computed query hash before any APQ operations occur. There's also a test case that verifies this behavior.
Learnt from: endigma
PR: wundergraph/cosmo#2181
File: router/core/operation_processor.go:0-0
Timestamp: 2025-09-02T12:52:27.677Z
Learning: Hash validation for persisted queries with query bodies is performed in router/core/graphql_prehandler.go in the handleOperation function, not in the APQ processing logic in operation_processor.go. This validates that extensions.persistedQuery.sha256Hash matches the query body before any APQ operations occur.
📚 Learning: 2025-09-02T12:52:27.677Z
Learnt from: endigma
PR: wundergraph/cosmo#2181
File: router/core/operation_processor.go:0-0
Timestamp: 2025-09-02T12:52:27.677Z
Learning: Hash validation for persisted queries with query bodies is performed in router/core/graphql_prehandler.go in the handleOperation function at lines 571-578, not in the APQ processing logic in operation_processor.go. This validates that extensions.persistedQuery.sha256Hash matches the computed query hash before any APQ operations occur. There's also a test case that verifies this behavior.

Applied to files:

  • router-tests/websocket_test.go
📚 Learning: 2025-09-02T12:52:27.677Z
Learnt from: endigma
PR: wundergraph/cosmo#2181
File: router/core/operation_processor.go:0-0
Timestamp: 2025-09-02T12:52:27.677Z
Learning: Hash validation for persisted queries with query bodies is performed in router/core/graphql_prehandler.go in the handleOperation function, not in the APQ processing logic in operation_processor.go. This validates that extensions.persistedQuery.sha256Hash matches the query body before any APQ operations occur.

Applied to files:

  • router-tests/websocket_test.go
📚 Learning: 2025-09-24T12:54:00.740Z
Learnt from: endigma
PR: wundergraph/cosmo#2222
File: router-tests/websocket_test.go:2238-2302
Timestamp: 2025-09-24T12:54:00.740Z
Learning: The wundergraph/cosmo project uses Go 1.25 (Go 1.23+ minimum), so fmt.Appendf and other newer Go standard library functions are available and can be used without compatibility concerns.

Applied to files:

  • router-tests/websocket_test.go
📚 Learning: 2025-09-24T12:54:00.740Z
Learnt from: endigma
PR: wundergraph/cosmo#2222
File: router-tests/websocket_test.go:2238-2302
Timestamp: 2025-09-24T12:54:00.740Z
Learning: The wundergraph/cosmo project uses Go 1.25 (Go 1.25 minimum), so fmt.Appendf and other newer Go standard library functions are available and can be used without compatibility concerns.

Applied to files:

  • router-tests/websocket_test.go
📚 Learning: 2025-08-20T22:13:25.222Z
Learnt from: StarpTech
PR: wundergraph/cosmo#2157
File: router-tests/go.mod:16-16
Timestamp: 2025-08-20T22:13:25.222Z
Learning: github.com/mark3labs/mcp-go v0.38.0 has regressions and should not be used in the wundergraph/cosmo project. v0.36.0 is the stable version that should be used across router-tests and other modules.

Applied to files:

  • router-tests/websocket_test.go
📚 Learning: 2025-08-28T09:18:10.121Z
Learnt from: endigma
PR: wundergraph/cosmo#2141
File: router-tests/http_subscriptions_test.go:100-108
Timestamp: 2025-08-28T09:18:10.121Z
Learning: In router-tests/http_subscriptions_test.go heartbeat tests, the message ordering should remain strict with data messages followed by heartbeat messages, as the timing is deterministic and known by design in the Cosmo router implementation.

Applied to files:

  • router-tests/websocket_test.go
🧬 Code graph analysis (1)
router-tests/websocket_test.go (3)
router-tests/testenv/testenv.go (7)
  • Run (107-124)
  • Config (286-342)
  • Environment (1729-1765)
  • WSWriteJSON (2785-2826)
  • WebSocketMessage (2281-2285)
  • WSReadJSON (2699-2740)
  • LogObservationConfig (388-391)
router/pkg/config/config.go (4)
  • Config (995-1069)
  • PersistedOperationsConfig (850-856)
  • SafelistConfiguration (858-860)
  • AutomaticPersistedQueriesConfig (862-866)
router/core/router.go (2)
  • Option (172-172)
  • WithPersistedOperationsConfig (2044-2048)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: integration_test (./telemetry)
  • GitHub Check: integration_test (./. ./fuzzquery ./lifecycle ./modules)
  • GitHub Check: integration_test (./events)
  • GitHub Check: Analyze (go)
🔇 Additional comments (4)
router-tests/websocket_test.go (4)

20-21: Import zapcore is appropriate

Needed for LogObservation LogLevel in new tests.


2103-2130: Good: sending only the hash (no query) with safelist enabled

Prevents cache-poisoning vectors and aligns with persisted-ops expectations.


2206-2236: LGTM: mismatched hash is correctly rejected

Validates the breaking-change behavior: mismatch blocks regardless of safelist.


2238-2302: Excellent cache‑poisoning prevention test

  • Sends mismatched query+hash, expects rejection.
  • Then sends only the hash and verifies the correct persisted op is served.
  • fmt.Appendf usage is fine given the project’s Go 1.25 toolchain.

Copy link
Copy Markdown
Member

@endigma endigma left a comment

Choose a reason for hiding this comment

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

lgtm

@dkorittki dkorittki merged commit 709483b into main Sep 26, 2025
28 checks passed
@dkorittki dkorittki deleted the dominik/eng-7506-subscription-registration-with-persisted-operation branch September 26, 2025 11:48
@Noroth Noroth mentioned this pull request Sep 30, 2025
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants