@bhooai/nexus-graphql 2.0.11 → 2.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,11 @@
1
1
  import { execute, parse, isObjectType, isInterfaceType, type DocumentNode, type SelectionSetNode, type FieldNode, type InlineFragmentNode, type FragmentSpreadNode, type ExecutionResult } from 'graphql';
2
+
3
+ /** Introspection root fields (`__schema`/`__type`) live on the merged schema, not any one subgraph. */
4
+ function isIntrospectionSelection(sel: SelectionSetNode): boolean {
5
+ return sel.selections.some(
6
+ (s) => s.kind === 'Field' && (s.name.value === '__schema' || s.name.value === '__type'),
7
+ );
8
+ }
2
9
  import type { Supergraph } from './composeSupergraph.js';
3
10
  import type { Subgraph, GraphQLContext } from '../types.js';
4
11
  import { createFieldResolver } from '../gateway/fieldResolver.js';
@@ -72,6 +79,19 @@ export async function executeFederated(
72
79
  if (!op) return { errors: [{ message: 'No operation in document' }] as unknown as NonNullable<ExecutionResult['errors']> };
73
80
  const rootType = op.operation === 'mutation' ? 'Mutation' : op.operation === 'subscription' ? 'Subscription' : 'Query';
74
81
 
82
+ // Introspection (`__schema`/`__type`) belongs to the merged schema, not a
83
+ // subgraph — execute it directly against the composed schema so the explorer
84
+ // Docs panel and standard clients can fetch the full schema.
85
+ if (isIntrospectionSelection(op.selectionSet)) {
86
+ return execute({
87
+ schema: supergraph.schema,
88
+ document,
89
+ rootValue: undefined,
90
+ contextValue,
91
+ variableValues,
92
+ }) as Promise<ExecutionResult>;
93
+ }
94
+
75
95
  const subgraphByName = new Map<string, Subgraph>(supergraph.subgraphs.map((s) => [s.name, s]));
76
96
 
77
97
  // 1. Root pass: group top-level fields by owner and execute trimmed subqueries.
@@ -64,17 +64,19 @@ function balancedBody(s: string, start: number): string {
64
64
  /** Parse field definitions in a type body into name → FieldMeta. */
65
65
  function parseFields(body: string): Map<string, FieldMeta> {
66
66
  const out = new Map<string, FieldMeta>();
67
- // Each field line roughly: `name(args): Type <directives>` separated by newlines.
68
- // We scan line-ish fragments split on top-level commas/newlines.
69
- const lines = body.split('\n').map((l) => l.trim()).filter(Boolean);
70
- for (const line of lines) {
71
- // Strip leading modifiers; capture field name as the first identifier.
72
- const nameMatch = /^(\w+)/.exec(line);
73
- if (!nameMatch) continue;
74
- const name = nameMatch[1]!;
75
- const external = /@external\b/.test(line);
76
- const provides = matchFields(line, '@provides');
77
- const requires = matchFields(line, '@requires');
67
+ // A field is `name(args)?: Type <directives>` a type like `[String!]!` never
68
+ // contains `@`, so a field run ends when the next field name (before its `:`)
69
+ // begins. Scanning with a regex (rather than splitting on newlines) handles
70
+ // bodies that pack many fields onto one line, e.g.
71
+ // type Message @key(fields: "id") { id: ID! roomId: String! text: String! }
72
+ const fieldRe = /(\w+)\s*(?:\([^)]*\))?\s*:\s*[\w\[\]!()]+\s*(?:@\w+(?:\s*\([^)]*\))?)*/g;
73
+ let m: RegExpExecArray | null;
74
+ while ((m = fieldRe.exec(body)) !== null) {
75
+ const name = m[1]!;
76
+ const text = m[0];
77
+ const external = /@external\b/.test(text);
78
+ const provides = matchFields(text, '@provides');
79
+ const requires = matchFields(text, '@requires');
78
80
  out.set(name, { external, provides, requires });
79
81
  }
80
82
  return out;
@@ -1,6 +1,7 @@
1
- import { parse, type DocumentNode } from 'graphql';
1
+ import { parse, type DocumentNode, type OperationDefinitionNode } from 'graphql';
2
2
  import type { RequestContext, Handler } from '@bhooai/nexus-core';
3
3
  import type { Gateway, GraphQLContext } from '../types.js';
4
+ import { verifyMutationCsrf } from '@bhooai/nexus-auth';
4
5
 
5
6
  export interface GraphqlHandlerOptions {
6
7
  /** The gateway to execute against. */
@@ -9,12 +10,23 @@ export interface GraphqlHandlerOptions {
9
10
  context?: (ctx: RequestContext) => GraphQLContext | Promise<GraphQLContext>;
10
11
  /** Allow introspection queries (default follows config.graphql.introspection = true). */
11
12
  introspection?: boolean;
13
+ /** Require a matching CSRF token to execute mutations (default true). The token may be sent via the `X-CSRF-Token` header or a `csrf` argument, and must match the double-submit cookie. Obtain it from GET /csrf-token or the GraphQL `csrfToken` query. */
14
+ requireMutationCsrf?: boolean;
12
15
  }
13
16
 
14
17
  interface GraphQLRequestBody {
15
18
  query?: string;
16
19
  variables?: Record<string, any>;
17
20
  operationName?: string;
21
+ csrf?: string;
22
+ }
23
+
24
+ /** True when any operation in the document is a mutation. */
25
+ function hasMutationOperation(doc: DocumentNode): boolean {
26
+ for (const def of doc.definitions) {
27
+ if (def.kind === 'OperationDefinition' && (def as OperationDefinitionNode).operation === 'mutation') return true;
28
+ }
29
+ return false;
18
30
  }
19
31
 
20
32
  /** True when a parsed operation set contains an introspection field (`__schema`/`__type`). */
@@ -33,15 +45,16 @@ function isIntrospectionDocument(doc: DocumentNode): boolean {
33
45
  /**
34
46
  * HTTP handler for `/graphql`. Accepts POST with `{ query, variables, operationName }`
35
47
  * (or GET with `?query=`). Applies the gateway in-process. Introspection is
36
- * rejected when disabled.
48
+ * rejected when disabled. Mutations additionally require a CSRF token (via the
49
+ * `X-CSRF-Token` header or a `csrf` argument) that matches the double-submit cookie.
37
50
  */
38
51
  export function graphqlHttpHandler(options: GraphqlHandlerOptions): Handler {
39
- const { gateway, introspection = true } = options;
52
+ const { gateway, introspection = true, requireMutationCsrf = true } = options;
40
53
  return async (ctx: RequestContext) => {
41
54
  let body: GraphQLRequestBody;
42
55
  if (ctx.method === 'GET') {
43
56
  const q = ctx.query.query;
44
- body = { query: Array.isArray(q) ? q[0] : q, variables: parseJsonQuery(ctx.query.variables), operationName: single(ctx.query.operationName) };
57
+ body = { query: Array.isArray(q) ? q[0] : q, variables: parseJsonQuery(ctx.query.variables), operationName: single(ctx.query.operationName), csrf: single(ctx.query.csrf) };
45
58
  } else {
46
59
  body = (ctx.body as GraphQLRequestBody) ?? {};
47
60
  }
@@ -64,6 +77,20 @@ export function graphqlHttpHandler(options: GraphqlHandlerOptions): Handler {
64
77
  return;
65
78
  }
66
79
 
80
+ // Mutations are unsafe: require a matching CSRF token equal to the double-submit
81
+ // cookie. The token may arrive as an argument (body.csrf, variables.csrf, GET
82
+ // ?csrf=) OR in the X-CSRF-Token header — either channel matching the cookie passes.
83
+ if (requireMutationCsrf && hasMutationOperation(document)) {
84
+ const headerToken = single(ctx.headers['x-csrf-token'] as string | string[] | undefined);
85
+ const argToken =
86
+ body.csrf ??
87
+ (body.variables && typeof body.variables === 'object' ? (body.variables as Record<string, unknown>).csrf as string : undefined);
88
+ if (!verifyMutationCsrf(ctx, argToken, headerToken)) {
89
+ ctx.json({ errors: [{ message: 'Invalid CSRF token: required for mutations.' }] }, 403);
90
+ return;
91
+ }
92
+ }
93
+
67
94
  const contextValue = options.context
68
95
  ? await options.context(ctx)
69
96
  : { request: ctx, user: (ctx.state.user as GraphQLContext['user']) };
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export * from './types.js';
2
+ export * from './mutationCsrf.js';
2
3
  export * from './subgraph/federationDirectives.js';
3
4
  export * from './subgraph/parseKeys.js';
4
5
  export * from './subgraph/defineSubgraph.js';
6
+ export * from './explorer/explorerHtml.js';
5
7
  export * from './gateway/fieldResolver.js';
6
8
  export * from './gateway/publicSchema.js';
7
9
  export * from './gateway/createGateway.js';
@@ -11,4 +13,5 @@ export * from './subscriptions/SubscriptionServer.js';
11
13
  export * from './federation/parseMetadata.js';
12
14
  export * from './federation/composeSupergraph.js';
13
15
  export * from './federation/executor.js';
14
- export * from './federation/createFederatedGateway.js';
16
+ export * from './federation/createFederatedGateway.js';
17
+ export * from './builtin/helloSubgraph.js';
@@ -0,0 +1,25 @@
1
+ import type { GraphQLContext } from './types.js';
2
+ import { verifyMutationCsrf } from '@bhooai/nexus-auth';
3
+
4
+ /**
5
+ * Validate a mutation's CSRF token against the request's double-submit cookie.
6
+ * The token may be passed as the `csrfToken` argument or in the `X-CSRF-Token`
7
+ * header — either channel matching the cookie passes. Throws when missing or
8
+ * mismatched, so per-mutation resolvers can gate writes explicitly (defense-in-depth
9
+ * on top of the HTTP-level enforcement).
10
+ */
11
+ export function assertMutationCsrf(context: GraphQLContext, csrfToken: string | undefined): void {
12
+ const ctx = context?.request as
13
+ | { headers?: Record<string, string | string[] | undefined> }
14
+ | undefined;
15
+ if (!ctx) throw new Error('Invalid CSRF token: required for this mutation.');
16
+ const headerToken =
17
+ ctx.headers?.['x-csrf-token'] !== undefined
18
+ ? Array.isArray(ctx.headers['x-csrf-token'])
19
+ ? (ctx.headers['x-csrf-token'] as string[])[0]
20
+ : (ctx.headers['x-csrf-token'] as string)
21
+ : undefined;
22
+ if (!verifyMutationCsrf(ctx as Parameters<typeof verifyMutationCsrf>[0], csrfToken, headerToken)) {
23
+ throw new Error('Invalid CSRF token: required for this mutation.');
24
+ }
25
+ }