From 5cd3f185063442e98e51851ead1f16b395c3fd08 Mon Sep 17 00:00:00 2001 From: dkijania Date: Mon, 29 Jun 2026 08:24:19 +0200 Subject: [PATCH 1/3] test(server): verify error masking; make masking explicit Yoga masks unexpected errors by default, but nothing guaranteed it stayed on or proved internals don't leak. - Set `maskedErrors: true` explicitly in the Yoga config so the production posture is intentional and can't be silently disabled. - Extract `buildYoga` from `buildServer` so the server's exact config is unit-testable. - Add tests proving a DB error carrying a password/connection string is returned to the client as a generic "Unexpected error." with no internals in the payload, while ordinary GraphQL validation errors still surface verbatim. Closes #177. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6 --- src/server/server.ts | 20 ++++++++--- tests/unit/error-masking.test.ts | 57 ++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 tests/unit/error-masking.test.ts diff --git a/src/server/server.ts b/src/server/server.ts index 23328244..a0708553 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -5,15 +5,20 @@ import { schema } from '../resolvers.js'; import type { GraphQLContext } from '../context.js'; import { useReadiness } from './readiness.js'; -export { BLOCK_RANGE_SIZE, ENABLE_BLOCK_TRANSACTION_DETAILS, buildServer }; +export { + BLOCK_RANGE_SIZE, + ENABLE_BLOCK_TRANSACTION_DETAILS, + buildYoga, + buildServer, +}; const LOG_LEVEL = (process.env.LOG_LEVEL as LogLevel) || 'info'; const BLOCK_RANGE_SIZE = Number(process.env.BLOCK_RANGE_SIZE) || 10000; const ENABLE_BLOCK_TRANSACTION_DETAILS = process.env.ENABLE_BLOCK_TRANSACTION_DETAILS === 'true'; -function buildServer(context: GraphQLContext, plugins: Plugin[]) { - const yoga = createYoga({ +function buildYoga(context: GraphQLContext, plugins: Plugin[]) { + return createYoga({ schema, logging: LOG_LEVEL, graphqlEndpoint: '/', @@ -21,6 +26,10 @@ function buildServer(context: GraphQLContext, plugins: Plugin[]) { // Liveness — the process is up and serving HTTP. healthCheckEndpoint: '/healthcheck', graphiql: process.env.ENABLE_GRAPHIQL === 'true' ? true : false, + // Mask unexpected (non-GraphQLError) errors so internal details — SQL, + // connection strings, stack traces — never reach clients. Explicit rather + // than relying on the default, so it can't be silently turned off. + maskedErrors: true, // Readiness (DB reachable) is prepended so probes short-circuit before any // other request hook (e.g. rate limiting) can interfere with them. plugins: [useReadiness(context.db_client), ...plugins], @@ -30,5 +39,8 @@ function buildServer(context: GraphQLContext, plugins: Plugin[]) { }, context, }); - return createServer(yoga); +} + +function buildServer(context: GraphQLContext, plugins: Plugin[]) { + return createServer(buildYoga(context, plugins)); } diff --git a/tests/unit/error-masking.test.ts b/tests/unit/error-masking.test.ts new file mode 100644 index 00000000..02271af0 --- /dev/null +++ b/tests/unit/error-masking.test.ts @@ -0,0 +1,57 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert'; +import { buildYoga } from '../../src/server/server.js'; +import type { GraphQLContext } from '../../src/context.js'; + +// A db_client whose query throws an error carrying sensitive internals — exactly +// the kind of message that must never reach a client. +const SENSITIVE = 'connection to server failed: password=topsecret'; + +function throwingContext(): GraphQLContext { + const fail = async () => { + throw new Error(SENSITIVE); + }; + return { + db_client: { + getEvents: fail, + getActions: fail, + getNetworkState: fail, + getBlocks: fail, + }, + } as unknown as GraphQLContext; +} + +describe('Error masking', () => { + test('masks unexpected resolver/DB errors and leaks no internals', async () => { + const yoga = buildYoga(throwingContext(), []); + const response = await yoga.fetch('http://localhost/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + query: '{ events(input: { address: "B62" }) { eventData { data } } }', + }), + }); + + const text = await response.text(); + const body = JSON.parse(text); + + // The client sees a generic masked error... + assert.ok(body.errors?.length, 'expected an error'); + assert.strictEqual(body.errors[0].message, 'Unexpected error.'); + // ...and none of the sensitive internals leak anywhere in the payload. + assert.ok(!text.includes('topsecret'), 'must not leak the raw error'); + assert.ok(!text.includes('password'), 'must not leak connection details'); + }); + + test('still surfaces ordinary GraphQL validation errors verbatim', async () => { + // Masking must not hide client-facing GraphQL errors (e.g. unknown field). + const yoga = buildYoga(throwingContext(), []); + const response = await yoga.fetch('http://localhost/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: '{ thisFieldDoesNotExist }' }), + }); + const body = await response.json(); + assert.match(body.errors[0].message, /thisFieldDoesNotExist/); + }); +}); From d299e05699cd53946ccee6023a81240538dcf19a Mon Sep 17 00:00:00 2001 From: dkijania Date: Fri, 17 Jul 2026 11:31:05 +0200 Subject: [PATCH 2/3] test(server): pin the error-string contract the mina-explorer depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validation-error test asserted the field name, which passes for any wording. The Explorer keys its fallback chains on the literal substring "Cannot query field" and silently degrades to the daemon on a match, so that string — not the field name — is the actual contract. Asserting it turns this into a regression guard: if masking, or a future yoga bump, ever reworded validation errors, Explorer pages would blank with nothing failing here. Also pins the 200-on-validation-error status. The Explorer's client throws on any non-2xx before reading the GraphQL body, and yoga only returns 400 under an Accept header it never sends — an implicit content-negotiation default that a future upgrade could flip unnoticed. Addresses review feedback on #195. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/error-masking.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/unit/error-masking.test.ts b/tests/unit/error-masking.test.ts index 02271af0..5edca8b9 100644 --- a/tests/unit/error-masking.test.ts +++ b/tests/unit/error-masking.test.ts @@ -52,6 +52,26 @@ describe('Error masking', () => { body: JSON.stringify({ query: '{ thisFieldDoesNotExist }' }), }); const body = await response.json(); + // The mina-explorer keys its field-fallback chains on this exact substring, + // silently degrading to the daemon on a match. Asserting the contract text + // rather than the field name makes this a regression guard for that client: + // masking, or a future yoga bump, rewording it would blank Explorer pages. + assert.match(body.errors[0].message, /Cannot query field/); assert.match(body.errors[0].message, /thisFieldDoesNotExist/); }); + + test('validation errors return HTTP 200 for a client sending no Accept header', async () => { + // The Explorer's client throws on any non-2xx before it ever reads the + // GraphQL body, so a 400 here would break its fallbacks outright. Yoga only + // switches to 400 under `Accept: application/graphql-response+json`, which + // that client never sends — an implicit content-negotiation default worth + // pinning, since a future upgrade could flip it unnoticed. + const yoga = buildYoga(throwingContext(), []); + const response = await yoga.fetch('http://localhost/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: '{ thisFieldDoesNotExist }' }), + }); + assert.strictEqual(response.status, 200); + }); }); From 2673e59d75ca209d076ab37013d58b77a4bc2b95 Mon Sep 17 00:00:00 2001 From: dkijania Date: Wed, 19 Aug 2026 13:45:51 +0200 Subject: [PATCH 3/3] fix(server): pin error masking behavior --- src/server/server.ts | 6 +-- tests/unit/error-masking.test.ts | 69 +++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/server/server.ts b/src/server/server.ts index a0708553..49c8ecc9 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -27,9 +27,9 @@ function buildYoga(context: GraphQLContext, plugins: Plugin[]) { healthCheckEndpoint: '/healthcheck', graphiql: process.env.ENABLE_GRAPHIQL === 'true' ? true : false, // Mask unexpected (non-GraphQLError) errors so internal details — SQL, - // connection strings, stack traces — never reach clients. Explicit rather - // than relying on the default, so it can't be silently turned off. - maskedErrors: true, + // connection strings, stack traces — never reach clients. `isDev: false` + // keeps Envelop from attaching original errors when NODE_ENV=development. + maskedErrors: { isDev: false }, // Readiness (DB reachable) is prepended so probes short-circuit before any // other request hook (e.g. rate limiting) can interfere with them. plugins: [useReadiness(context.db_client), ...plugins], diff --git a/tests/unit/error-masking.test.ts b/tests/unit/error-masking.test.ts index 5edca8b9..56ed5f49 100644 --- a/tests/unit/error-masking.test.ts +++ b/tests/unit/error-masking.test.ts @@ -7,6 +7,29 @@ import type { GraphQLContext } from '../../src/context.js'; // the kind of message that must never reach a client. const SENSITIVE = 'connection to server failed: password=topsecret'; +const VALIDATION_CASES: Array<[string, string, RegExp]> = [ + [ + 'Cannot query field', + '{ blocks(query: { blockHeight_lt: 10 }, limit: 1) { protocolState { consensusState { epoch } } } }', + /^Cannot query field "protocolState" on type "Block"\./, + ], + [ + 'unknown input field for inBestChain detection', + '{ blocks(query: { inBestChainX: true }, limit: 1) { blockHeight } }', + /Field "inBestChainX" is not defined by type "BlockQueryInput"\./, + ], + [ + 'Unknown argument', + '{ blocks(query: { blockHeight_lt: 10 }, limit: 1, bogus: 3) { blockHeight } }', + /^Unknown argument "bogus" on field "Query\.blocks"\./, + ], + [ + 'Unknown type', + 'query Q($x: NoSuchInput!) { blocks(query: { blockHeight_lt: 10 }, limit: 1) { blockHeight } }', + /^Unknown type "NoSuchInput"\./, + ], +]; + function throwingContext(): GraphQLContext { const fail = async () => { throw new Error(SENSITIVE); @@ -43,8 +66,52 @@ describe('Error masking', () => { assert.ok(!text.includes('password'), 'must not leak connection details'); }); + test('masks unexpected errors without dev extensions under NODE_ENV=development', async () => { + const previousNodeEnv = process.env.NODE_ENV; + try { + process.env.NODE_ENV = 'development'; + + const yoga = buildYoga(throwingContext(), []); + const response = await yoga.fetch('http://localhost/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + query: '{ events(input: { address: "B62" }) { eventData { data } } }', + }), + }); + + const text = await response.text(); + const body = JSON.parse(text); + assert.strictEqual(body.errors[0].message, 'Unexpected error.'); + assert.ok(!text.includes('topsecret'), 'must not leak the raw error'); + assert.ok(!text.includes('originalError'), 'must not leak dev details'); + assert.ok(!text.includes('stack'), 'must not leak stack traces'); + } finally { + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + } + }); + + for (const [name, query, expected] of VALIDATION_CASES) { + test(`validation error reaches the client verbatim: ${name}`, async () => { + // Masking must not hide client-facing GraphQL errors. These are the + // marker strings downstream consumers use for schema fallback behavior. + const yoga = buildYoga(throwingContext(), []); + const response = await yoga.fetch('http://localhost/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query }), + }); + const body = await response.json(); + assert.strictEqual(response.status, 200); + assert.match(body.errors[0].message, expected); + }); + } + test('still surfaces ordinary GraphQL validation errors verbatim', async () => { - // Masking must not hide client-facing GraphQL errors (e.g. unknown field). const yoga = buildYoga(throwingContext(), []); const response = await yoga.fetch('http://localhost/', { method: 'POST',