Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const deployOptions = getDeploymentEnvOptions();
| `SERVER_TRUST_PROXY` | Trust proxy headers |
| `SERVER_ORIGIN` | Server origin URL |
| `SERVER_STRICT_AUTH` | Strict authentication mode |
| `SERVER_EXPOSE_ERRORS` | Return raw internal errors to clients (local debugging only; default `false`, masked) |

### CDN/Storage

Expand Down
1 change: 1 addition & 0 deletions graphql/env/__tests__/__snapshots__/merge.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and
"user": "env-user",
},
"server": {
"exposeErrors": false,
"host": "localhost",
"port": 5000,
"strictAuth": false,
Expand Down
25 changes: 25 additions & 0 deletions graphql/server-test/__fixtures__/seed/error-masking/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Error-masking fixture: a table the anonymous role holds no grant on, a
-- mutation refused with a registered public code, and a query that fails with
-- an unexpected database error.
--
-- Compose after app-schemas/simple-pets/schema.sql and scoped/test-data.sql.

CREATE TABLE "simple-pets-public".vault_items (
id serial PRIMARY KEY,
secret text NOT NULL
);
REVOKE ALL ON "simple-pets-public".vault_items FROM anonymous, authenticated, PUBLIC;

CREATE FUNCTION "simple-pets-public".invite_member(address text)
RETURNS boolean AS $$
BEGIN
RAISE EXCEPTION 'INVITE_ADDRESS_REQUIRED';
END;
$$ LANGUAGE plpgsql VOLATILE;

CREATE FUNCTION "simple-pets-public".vault_ratio()
RETURNS integer AS $$
BEGIN
RETURN 1 / 0;
END;
$$ LANGUAGE plpgsql STABLE;
101 changes: 101 additions & 0 deletions graphql/server-test/__tests__/error-masking.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* What an anonymous client of a public API sees when a request fails, over
* real HTTP through the scoped-routing server.
*
* Masking must hold whatever NODE_ENV says: the suite runs with NODE_ENV
* `development` — what an unset NODE_ENV reads as — so a deployment that
* forgets to set it still never returns raw PostgreSQL errors.
*
* Run tests:
* pnpm test -- --testPathPattern=error-masking
*/
import path from 'path';
import type supertest from 'supertest';

import { getConnections, seed } from '../src';

jest.setTimeout(30000);

const sharedSeedRoot = path.join(__dirname, '..', '..', '..', '__fixtures__', 'seed');
const localSeedRoot = path.join(__dirname, '..', '__fixtures__', 'seed', 'error-masking');
const pgpmWorkspace = path.join(sharedSeedRoot, '..', '..');
const metaSchemas = [
'catalog_private',
'routing_public',
'apps_public',
'metaschema_public',
'metaschema_modules_public'
];

const HOST = 'app.test.constructive.io';
const MASKED = /^An unexpected error occurred\. Reference: [0-9a-f]{16}$/;

const nodeEnv = process.env.NODE_ENV;
let request: supertest.Agent;
let teardown: () => Promise<void>;

const post = async (query: string) => {
const res = await request.post('/graphql').set('Host', HOST).send({ query });
expect(res.status).toBe(200);
return res.body as {
data?: unknown;
errors?: { message: string; extensions?: Record<string, unknown> }[];
extensions?: unknown;
};
};

beforeAll(async () => {
process.env.NODE_ENV = 'development';
({ request, teardown } = await getConnections(
{
schemas: ['simple-pets-public', 'simple-pets-pets-public'],
authRole: 'anonymous',
server: { useRouting: true, api: { isPublic: true, metaSchemas } }
},
[
seed.pgpm(pgpmWorkspace),
seed.sqlfile([
path.join(sharedSeedRoot, 'app-schemas', 'simple-pets', 'schema.sql'),
path.join(sharedSeedRoot, 'scoped', 'test-data.sql'),
path.join(localSeedRoot, 'schema.sql')
])
]
));
});

afterAll(async () => {
await teardown();
process.env.NODE_ENV = nodeEnv;
});

describe('error masking for anonymous callers', () => {
it('answers a read of a table anon holds no grant on with FORBIDDEN, naming nothing', async () => {
const body = await post('{ vaultItems { nodes { id secret } } }');

expect(body.errors).toHaveLength(1);
expect(body.errors![0].extensions?.code).toBe('FORBIDDEN');
expect(body.errors![0].message).toBe('You do not have permission to do that.');
expect(body.extensions).toBeUndefined();
expect(JSON.stringify(body)).not.toMatch(/vault_items|simple-pets|permission denied/);
});

it('passes a registered Constructive error code through unchanged', async () => {
const body = await post('mutation { inviteMember(input: { address: "" }) { clientMutationId } }');

expect(body.errors).toHaveLength(1);
expect(body.errors![0].message).toBe('INVITE_ADDRESS_REQUIRED');
expect(body.errors![0].extensions?.code).toBe('INVITE_ADDRESS_REQUIRED');
});

it('masks an unexpected database error behind a reference id', async () => {
const body = await post('{ vaultRatio }');

expect(body.errors).toHaveLength(1);
expect(body.errors![0].message).toMatch(MASKED);
expect(body.errors![0].extensions).toEqual({
code: 'INTERNAL_SERVER_ERROR',
errorId: expect.any(String)
});
expect(JSON.stringify(body)).not.toMatch(/division by zero/);
});
});
14 changes: 6 additions & 8 deletions graphql/server-test/__tests__/upload.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,11 +222,11 @@ const DELETE_APP_BUCKET = `
* Assert that a mutation was denied specifically by RLS (not by some other error).
*
* PostgreSQL RLS denials surface in three ways through PostGraphile:
* 1. An explicit PG error — message contains "permission denied",
* "new row violates row-level security", or "No values were".
* 2. A masked internal error — in production mode PostGraphile masks
* PG errors with code INTERNAL_SERVER_ERROR (the raw message is
* only logged server-side).
* 1. A privilege refusal — a missing grant or an RLS refusal (SQLSTATE
* 42501) is surfaced as FORBIDDEN, or the mutation matched no row
* ("No values were ...").
* 2. A masked internal error — code INTERNAL_SERVER_ERROR (the raw
* message is only logged server-side).
* 3. The mutation silently affects 0 rows and returns null or an
* object with all-null fields (RLS USING clause filtered the row).
*
Expand All @@ -244,9 +244,7 @@ function expectRlsDenied(
// Reject GraphQL validation errors — these indicate a bug in the test
expect(code).not.toBe('GRAPHQL_VALIDATION_FAILED');
expect(
msg.includes('permission denied') ||
msg.includes('new row violates row-level security') ||
msg.includes('insufficient_privilege') ||
code === 'FORBIDDEN' ||
msg.includes('No values were') ||
code === 'INTERNAL_SERVER_ERROR'
).toBe(true);
Expand Down
35 changes: 34 additions & 1 deletion graphql/server/src/middleware/__tests__/mask-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ const run = async (query: string, variables?: Record<string, unknown>) => {
describe('maskError', () => {
const nodeEnv = process.env.NODE_ENV;

// Masking must not depend on NODE_ENV: a deployment that leaves it unset (which
// reads as development) still masks.
beforeAll(() => {
process.env.NODE_ENV = 'production';
process.env.NODE_ENV = 'development';
});

afterAll(() => {
Expand Down Expand Up @@ -114,4 +116,35 @@ describe('maskError', () => {
expect(result.extensions?.code).toBe('INTERNAL_SERVER_ERROR');
expect(result.extensions?.errorId).toEqual(expect.any(String));
});

it('passes an internal error through only when exposeErrors is opted into', async () => {
const [error] = (await execute({ schema, document: parse('mutation{ brokenField }') })).errors ?? [];

const result = maskError(error, { exposeErrors: true }) as { message: string };

expect(result.message).toBe('relation "internal_secrets" does not exist');
});

it('surfaces a native privilege refusal as FORBIDDEN without naming the table', () => {
const pgError = Object.assign(new Error('permission denied for table agent_thread'), { code: '42501' });
const error = new GraphQLError(pgError.message, { path: ['agentThreads'], originalError: pgError });

const result = maskError(error) as { message: string; extensions?: Record<string, unknown> };

expect(result.message).toBe('You do not have permission to do that.');
expect(result.message).not.toContain('agent_thread');
expect(result.extensions?.code).toBe('FORBIDDEN');
expect(result.extensions?.class).toBe('public');
expect(result.extensions?.errorId).toBeUndefined();
});

it('passes a registered public code through unchanged', () => {
const pgError = Object.assign(new Error('INVITE_ADDRESS_REQUIRED'), { code: 'P0001' });
const error = new GraphQLError(pgError.message, { path: ['submitInvite'], originalError: pgError });

const result = maskError(error) as { message: string; extensions?: Record<string, unknown> };

expect(result.message).toBe('INVITE_ADDRESS_REQUIRED');
expect(result.extensions?.code).toBe('INVITE_ADDRESS_REQUIRED');
});
});
4 changes: 1 addition & 3 deletions graphql/server/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import './types'; // for Request type

import { errors } from '@constructive-io/errors';
import { getNodeEnv } from '@pgpmjs/env';
import { Logger } from '@pgpmjs/logger';
import { PgpmOptions } from '@pgpmjs/types';
import { NextFunction, Request, RequestHandler, Response } from 'express';
Expand All @@ -11,7 +10,6 @@ import pgQueryContext from 'pg-query-context';
import { respondWithGraphQLError } from '../errors/graphql-response';

const log = new Logger('auth');
const isDev = () => getNodeEnv() === 'development';

/** Default cookie name for session tokens. */
const SESSION_COOKIE_NAME = 'constructive_session';
Expand Down Expand Up @@ -127,7 +125,7 @@ export const createAuthenticateMiddleware = (
respondWithGraphQLError(
res,
errors.INTERNAL_FAILURE({
details: isDev() ? e.message : 'authentication failed',
details: opts.server?.exposeErrors ? e.message : 'authentication failed',
})
);
return;
Expand Down
39 changes: 22 additions & 17 deletions graphql/server/src/middleware/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ const wantsJson = (req: Request): boolean => {
|| Boolean(req.is('json'));
};

const sanitizeMessage = (error: Error): string => {
if (isDevelopment()) return error.message;
const sanitizeMessage = (error: Error, exposeErrors: boolean): string => {
if (exposeErrors) return error.message;
if (isApiError(error)) return error.message;
if (error.message?.includes('ECONNREFUSED')) return 'Service temporarily unavailable';
if (error.message?.includes('timeout') || error.message?.includes('ETIMEDOUT')) return 'Request timed out';
Expand All @@ -40,12 +40,12 @@ const isCsrfError = (err: Error): boolean => {
return typeof code === 'string' && code.startsWith('CSRF_');
};

const categorizeError = (err: Error): ErrorResponse => {
const categorizeError = (err: Error, exposeErrors: boolean): ErrorResponse => {
if (isApiError(err)) {
return {
statusCode: err.statusCode,
code: err.code,
message: sanitizeMessage(err),
message: sanitizeMessage(err, exposeErrors),
logLevel: err.statusCode >= 500 ? 'error' : 'warn',
};
}
Expand All @@ -54,12 +54,12 @@ const categorizeError = (err: Error): ErrorResponse => {
return { statusCode: 403, code, message: err.message, logLevel: 'warn' };
}
if (err.message?.includes('ECONNREFUSED') || err.message?.includes('connection terminated')) {
return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message: sanitizeMessage(err), logLevel: 'error' };
return { statusCode: 503, code: 'SERVICE_UNAVAILABLE', message: sanitizeMessage(err, exposeErrors), logLevel: 'error' };
}
if (err.message?.includes('timeout') || err.message?.includes('ETIMEDOUT')) {
return { statusCode: 504, code: 'GATEWAY_TIMEOUT', message: sanitizeMessage(err), logLevel: 'error' };
return { statusCode: 504, code: 'GATEWAY_TIMEOUT', message: sanitizeMessage(err, exposeErrors), logLevel: 'error' };
}
return { statusCode: 500, code: 'INTERNAL_ERROR', message: sanitizeMessage(err), logLevel: 'error' };
return { statusCode: 500, code: 'INTERNAL_ERROR', message: sanitizeMessage(err, exposeErrors), logLevel: 'error' };
};

const sendResponse = (req: Request, res: Response, { statusCode, code, message }: ErrorResponse): void => {
Expand Down Expand Up @@ -88,16 +88,21 @@ const logError = (err: Error, req: Request, level: 'warn' | 'error'): void => {
}
};

export const errorHandler: ErrorRequestHandler = (err: Error, req: Request, res: Response, _next: NextFunction): void => {
if (res.headersSent) {
log.warn({ event: 'headers_already_sent', requestId: req.requestId, path: req.path, errorMessage: err.message });
return;
}

const response = categorizeError(err);
logError(err, req, response.logLevel);
sendResponse(req, res, response);
};
/**
* Express error handler. Unexpected errors reach the client as a generic
* message unless `exposeErrors` (`server.exposeErrors`) is explicitly set.
*/
export const createErrorHandler = ({ exposeErrors = false }: { exposeErrors?: boolean } = {}): ErrorRequestHandler =>
(err: Error, req: Request, res: Response, _next: NextFunction): void => {
if (res.headersSent) {
log.warn({ event: 'headers_already_sent', requestId: req.requestId, path: req.path, errorMessage: err.message });
return;
}

const response = categorizeError(err, exposeErrors);
logError(err, req, response.logLevel);
sendResponse(req, res, response);
};

export const notFoundHandler = (req: Request, res: Response, _next: NextFunction): void => {
log.warn({ event: 'route_not_found', path: req.path, method: req.method, requestId: req.requestId });
Expand Down
12 changes: 6 additions & 6 deletions graphql/server/src/middleware/graphile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { errors } from '@constructive-io/errors';
import type { ComputeConfig } from '@constructive-io/express-context';
import { DEFAULT_REQUEST_PROTECTION, protectionPgSettings } from '@constructive-io/express-context';
import type { ConstructiveOptions } from '@constructive-io/graphql-types';
import { getNodeEnv } from '@pgpmjs/env';
import { Logger } from '@pgpmjs/logger';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { createGraphileInstance, graphileCache,type GraphileCacheEntry } from 'graphile-cache';
Expand All @@ -28,8 +27,6 @@ import { makeIntrospectionWiring } from './graphile-introspection';
import { maskError } from './mask-error';
import { observeGraphileBuild } from './observability/graphile-build-stats';

const isDev = (): boolean => getNodeEnv() === 'development';

// =============================================================================
// Single-Flight Pattern: In-Flight Tracking
// =============================================================================
Expand Down Expand Up @@ -82,6 +79,7 @@ const buildPreset = async (
roleName: string,
introspectionRole: string | undefined,
graphileOptions: ConstructiveOptions['graphile'],
exposeErrors: boolean,
databaseSettings?: DatabaseSettings,
apiId?: string,
compute?: ComputeConfig
Expand Down Expand Up @@ -131,10 +129,10 @@ const buildPreset = async (
graphiqlPath: '/graphiql',
graphiql: true,
graphiqlOnGraphQLGET: false,
maskError
maskError: (error) => maskError(error, { exposeErrors })
},
grafast: {
explain: process.env.NODE_ENV === 'development',
explain: exposeErrors,
context: (requestContext: Partial<Grafast.RequestContext>) => {
// In grafserv/express/v4, the request is available at requestContext.expressv4.req
const req = (requestContext as { expressv4?: { req?: Request } })?.expressv4?.req;
Expand Down Expand Up @@ -281,6 +279,7 @@ const buildPreset = async (

export const graphile = (opts: ConstructiveOptions): RequestHandler => {
const observabilityEnabled = isGraphqlObservabilityEnabled(opts.server?.host);
const exposeErrors = opts.server?.exposeErrors ?? false;

return async (req: Request, res: Response, next: NextFunction) => {
const label = reqLabel(req);
Expand Down Expand Up @@ -372,6 +371,7 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => {
roleName,
opts.api?.introspectionRole,
opts.graphile,
exposeErrors,
api.databaseSettings,
api.apiId,
compute
Expand Down Expand Up @@ -416,7 +416,7 @@ export const graphile = (opts: ConstructiveOptions): RequestHandler => {
respondWithGraphQLError(
res,
errors.INTERNAL_FAILURE({
details: isDev() ? e?.message ?? String(e) : 'An unexpected error occurred'
details: exposeErrors ? e?.message ?? String(e) : 'An unexpected error occurred'
})
);
return;
Expand Down
Loading
Loading