@bhooai/nexus-graphql 2.0.10 → 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.
- package/package.json +6 -1
- package/src/builtin/helloSubgraph.ts +38 -0
- package/src/builtin/index.ts +3 -0
- package/src/explorer/explorerHtml.ts +1495 -0
- package/src/federation/executor.ts +20 -0
- package/src/federation/parseMetadata.ts +13 -11
- package/src/gateway/graphqlHttpHandler.ts +31 -4
- package/src/index.ts +4 -1
- package/src/mutationCsrf.ts +25 -0
|
@@ -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
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
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
|
+
}
|