Skip to content

Commit eabdd54

Browse files
authored
feat/expose-limits (#717)
* feat(packages/plugins/character-limit): add exposeLimits and errorMessage options * feat(packages/plugins/cost-limit): add exposeLimits and errorMessage options * feat(packages/plugins/max-aliases): add exposeLimits and errorMessage options * feat(packages/plugins/max-depth): add exposeLimits and errorMessage options * feat(packages/plugins/max-tokens): add exposeLimits and errorMessage options * fix(plugins): ensure proper newline at end of character-limit file * fix(plugins): ensure proper newline at end of cost-limit file * fix(plugins): ensure proper newline at end of max-aliases file * fix(plugins): ensure proper newline at end of max-depth file * fix(plugins): ensure proper newline at end of max-tokens file * fix: revert max tokens * feat(plugins): expose limits in default options * test(character-limit): update tests for character limit plugin * test(cost-limit): update tests for cost limit plugin * test(max-aliases): update tests for max aliases plugin * test(max-depth): update tests for max depth plugin * test(max-directives): update tests for max directives plugin * test(max-tokens): update tests for max tokens plugin * fix(test/character-limit): clean up error messages and remove unused tests * fix(test/cost-limit): update cost parameters and remove unused tests * fix(test/max-aliases): simplify alias checks and remove unused tests * fix(test/max-depth): adjust depth limits and clean up tests * fix(test/max-directives): refine directive limits and clear out tests * fix(src/max-tokens): ensure consistent end of file formatting * fix(test/max-tokens): format operation strings for readability * feat: changeset
1 parent b1b5001 commit eabdd54

File tree

13 files changed

+426
-94
lines changed

13 files changed

+426
-94
lines changed

.changeset/lemon-suns-chew.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@escape.tech/graphql-armor-character-limit': minor
3+
'@escape.tech/graphql-armor-max-directives': minor
4+
'@escape.tech/graphql-armor-max-aliases': minor
5+
'@escape.tech/graphql-armor-cost-limit': minor
6+
'@escape.tech/graphql-armor-max-tokens': minor
7+
'@escape.tech/graphql-armor-max-depth': minor
8+
'@escape.tech/graphql-armor': minor
9+
'@escape.tech/graphql-armor-types': minor
10+
---
11+
12+
Add exposeLimit [!716](https://github.com/Escape-Technologies/graphql-armor/issues/716)

packages/plugins/character-limit/src/index.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,29 @@ import type { Plugin } from '@envelop/core';
22
import type { GraphQLArmorCallbackConfiguration } from '@escape.tech/graphql-armor-types';
33
import { GraphQLError } from 'graphql';
44

5-
export type CharacterLimitOptions = { maxLength?: number } & GraphQLArmorCallbackConfiguration;
5+
export type CharacterLimitOptions = {
6+
maxLength?: number;
7+
exposeLimits?: boolean;
8+
errorMessage?: string;
9+
} & GraphQLArmorCallbackConfiguration;
10+
611
export const characterLimitDefaultOptions: Required<CharacterLimitOptions> = {
712
maxLength: 15000,
13+
exposeLimits: true,
14+
errorMessage: 'Query validation error.',
815
onAccept: [],
916
onReject: [],
1017
propagateOnRejection: true,
1118
};
1219

13-
/* CharacterLimitPlugin Supports Apollo Server v3 and ver4 */
14-
export const ApolloServerCharacterLimitPlugin = function (maxLength: number): any {
20+
/* CharacterLimitPlugin Supports Apollo Server v3 and v4 */
21+
export const ApolloServerCharacterLimitPlugin = function (options?: CharacterLimitOptions): any {
22+
const config = Object.assign(
23+
{},
24+
characterLimitDefaultOptions,
25+
...Object.entries(options ?? {}).map(([k, v]) => (v === undefined ? {} : { [k]: v })),
26+
);
27+
1528
return {
1629
requestDidStart(requestContext: any): Promise<any> | undefined {
1730
const { request } = requestContext;
@@ -21,11 +34,18 @@ export const ApolloServerCharacterLimitPlugin = function (maxLength: number): an
2134
}
2235
const queryLength = request.query.length;
2336

24-
if (queryLength > maxLength) {
25-
throw new GraphQLError(`Query exceeds our maximum allowed length`, {
37+
if (queryLength > config.maxLength) {
38+
const message = config.exposeLimits
39+
? `Query exceeds the maximum allowed length of ${config.maxLength}, found ${queryLength}.`
40+
: config.errorMessage;
41+
throw new GraphQLError(message, {
2642
extensions: { code: 'BAD_USER_INPUT' },
2743
});
2844
}
45+
46+
for (const handler of config.onAccept) {
47+
handler(null, { queryLength });
48+
}
2949
},
3050
};
3151
};
@@ -38,14 +58,16 @@ export const characterLimitPlugin = (options?: CharacterLimitOptions): Plugin =>
3858
);
3959

4060
return {
41-
onParse({ parseFn, setParseFn }) {
42-
setParseFn((source, options) => {
61+
onParse({ parseFn, setParseFn }: any) {
62+
setParseFn((source: string | { body: string }, options: any) => {
4363
const query = typeof source === 'string' ? source : source.body;
64+
const queryLength = query.length;
4465

45-
if (query && query.length > config.maxLength) {
46-
const err = new GraphQLError(
47-
`Syntax Error: Character limit of ${config.maxLength} exceeded, found ${query.length}.`,
48-
);
66+
if (query && queryLength > config.maxLength) {
67+
const message = config.exposeLimits
68+
? `Character limit of ${config.maxLength} exceeded, found ${queryLength}.`
69+
: config.errorMessage;
70+
const err = new GraphQLError(`Syntax Error: ${message}`);
4971

5072
for (const handler of config.onReject) {
5173
handler(null, err);
@@ -56,7 +78,7 @@ export const characterLimitPlugin = (options?: CharacterLimitOptions): Plugin =>
5678
}
5779
} else {
5880
for (const handler of config.onAccept) {
59-
handler(null, { query });
81+
handler(null, { queryLength });
6082
}
6183
}
6284

packages/plugins/character-limit/test/index.spec.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const typeDefinitions = `
1414
books: [Book]
1515
}
1616
`;
17+
1718
const books = [
1819
{
1920
title: 'The Awakening',
@@ -36,7 +37,7 @@ const schema = makeExecutableSchema({
3637
typeDefs: [typeDefinitions],
3738
});
3839

39-
describe('global', () => {
40+
describe('characterLimitPlugin', () => {
4041
it('should be defined', () => {
4142
expect(characterLimitPlugin).toBeDefined();
4243

@@ -52,7 +53,7 @@ describe('global', () => {
5253
}
5354
}`;
5455

55-
it('should works by default', async () => {
56+
it('should work by default', async () => {
5657
const testkit = createTestkit([], schema);
5758
const result = await testkit.execute(query);
5859

@@ -63,16 +64,16 @@ describe('global', () => {
6364
});
6465
});
6566

66-
it('should reject query', async () => {
67+
it('should reject query exceeding max length', async () => {
6768
const length = query.length - 1;
6869
const testkit = createTestkit([characterLimitPlugin({ maxLength: length })], schema);
6970
const result = await testkit.execute(query);
7071

7172
assertSingleExecutionValue(result);
7273
expect(result.errors).toBeDefined();
73-
expect(result.errors?.map((error) => error.message)).toEqual([
74+
expect(result.errors?.[0].message).toEqual(
7475
`Syntax Error: Character limit of ${length} exceeded, found ${length + 1}.`,
75-
]);
76+
);
7677
});
7778

7879
it('should not limit query variables', async () => {
@@ -90,4 +91,46 @@ describe('global', () => {
9091
books: books,
9192
});
9293
});
94+
95+
it('rejects with a generic error message when exposeLimits is false', async () => {
96+
const length = 10;
97+
const customMessage = 'Custom error message.';
98+
const testkit = createTestkit(
99+
[
100+
characterLimitPlugin({
101+
maxLength: length,
102+
exposeLimits: false,
103+
errorMessage: customMessage,
104+
}),
105+
],
106+
schema,
107+
);
108+
const longQuery = 'query { ' + 'a'.repeat(length + 1) + ' }';
109+
const result = await testkit.execute(longQuery);
110+
111+
assertSingleExecutionValue(result);
112+
expect(result.errors).toBeDefined();
113+
expect(result.errors?.[0].message).toEqual(`Syntax Error: ${customMessage}`);
114+
});
115+
116+
it('rejects with detailed error message when exposeLimits is true', async () => {
117+
const length = 10;
118+
const testkit = createTestkit(
119+
[
120+
characterLimitPlugin({
121+
maxLength: length,
122+
exposeLimits: true,
123+
}),
124+
],
125+
schema,
126+
);
127+
const longQuery = 'query { ' + 'a'.repeat(length + 2) + ' }';
128+
const result = await testkit.execute(longQuery);
129+
130+
assertSingleExecutionValue(result);
131+
expect(result.errors).toBeDefined();
132+
expect(result.errors?.[0].message).toEqual(
133+
`Syntax Error: Character limit of ${length} exceeded, found ${length + 10 + 2}.`,
134+
);
135+
});
93136
});

packages/plugins/cost-limit/src/index.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,19 @@ export type CostLimitOptions = {
1818
depthCostFactor?: number;
1919
ignoreIntrospection?: boolean;
2020
fragmentRecursionCost?: number;
21+
exposeLimits?: boolean;
22+
errorMessage?: string;
2123
} & GraphQLArmorCallbackConfiguration;
24+
2225
const costLimitDefaultOptions: Required<CostLimitOptions> = {
2326
maxCost: 5000,
2427
objectCost: 2,
2528
scalarCost: 1,
2629
depthCostFactor: 1.5,
2730
fragmentRecursionCost: 1000,
2831
ignoreIntrospection: true,
32+
exposeLimits: true,
33+
errorMessage: 'Query validation error.',
2934
onAccept: [],
3035
onReject: [],
3136
propagateOnRejection: true,
@@ -48,16 +53,17 @@ class CostLimitVisitor {
4853
this.visitedFragments = new Map();
4954

5055
this.OperationDefinition = {
51-
enter: this.onOperationDefinitionEnter,
56+
enter: this.onOperationDefinitionEnter.bind(this),
5257
};
5358
}
5459

5560
onOperationDefinitionEnter(operation: OperationDefinitionNode): void {
5661
const complexity = this.computeComplexity(operation);
5762
if (complexity > this.config.maxCost) {
58-
const err = new GraphQLError(
59-
`Syntax Error: Query Cost limit of ${this.config.maxCost} exceeded, found ${complexity}.`,
60-
);
63+
const message = this.config.exposeLimits
64+
? `Query Cost limit of ${this.config.maxCost} exceeded, found ${complexity}.`
65+
: this.config.errorMessage;
66+
const err = new GraphQLError(`Syntax Error: ${message}`);
6167

6268
for (const handler of this.config.onReject) {
6369
handler(this.context, err);
@@ -81,7 +87,7 @@ class CostLimitVisitor {
8187
return 0;
8288
}
8389

84-
if (node.kind == Kind.OPERATION_DEFINITION) {
90+
if (node.kind === Kind.OPERATION_DEFINITION) {
8591
return node.selectionSet.selections.reduce((v, child) => v + this.computeComplexity(child, depth + 1), 0);
8692
}
8793

@@ -93,7 +99,7 @@ class CostLimitVisitor {
9399
}
94100
}
95101

96-
if (node.kind == Kind.FRAGMENT_SPREAD) {
102+
if (node.kind === Kind.FRAGMENT_SPREAD) {
97103
if (this.visitedFragments.has(node.name.value)) {
98104
const visitCost = this.visitedFragments.get(node.name.value) ?? 0;
99105
return cost + this.config.depthCostFactor * visitCost;

packages/plugins/cost-limit/test/index.spec.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const typeDefinitions = `
1616
getBook(title: String): Book
1717
}
1818
`;
19+
1920
const books = [
2021
{
2122
title: 'The Awakening',
@@ -30,7 +31,7 @@ const books = [
3031
const resolvers = {
3132
Query: {
3233
books: () => books,
33-
getBook: (title: string) => books.find((book) => book.title === title),
34+
getBook: (_: any, { title }: { title: string }) => books.find((book) => book.title === title),
3435
},
3536
};
3637

@@ -39,7 +40,7 @@ export const schema = makeExecutableSchema({
3940
typeDefs: [typeDefinitions],
4041
});
4142

42-
describe('global', () => {
43+
describe('costLimitPlugin', () => {
4344
it('should be defined', () => {
4445
expect(costLimitPlugin).toBeDefined();
4546

@@ -55,7 +56,7 @@ describe('global', () => {
5556
}
5657
}`;
5758

58-
it('should works for default query', async () => {
59+
it('should work for default query', async () => {
5960
const testkit = createTestkit([], schema);
6061
const result = await testkit.execute(query);
6162

@@ -90,7 +91,7 @@ describe('global', () => {
9091
const testkit = createTestkit(
9192
[
9293
costLimitPlugin({
93-
maxCost: 11 - 1,
94+
maxCost: 10,
9495
objectCost: 1,
9596
scalarCost: 1,
9697
depthCostFactor: 2,
@@ -169,4 +170,52 @@ describe('global', () => {
169170
expect(result.errors).toBeDefined();
170171
expect(result.errors?.map((error) => error.message)).toContain('Cannot spread fragment "A" within itself via "B".');
171172
});
173+
174+
it('rejects with a generic error message when exposeLimits is false', async () => {
175+
const maxCost = 10;
176+
const customMessage = 'Custom error message.';
177+
const testkit = createTestkit(
178+
[
179+
costLimitPlugin({
180+
maxCost: maxCost,
181+
exposeLimits: false,
182+
errorMessage: customMessage,
183+
objectCost: 4,
184+
scalarCost: 2,
185+
depthCostFactor: 2,
186+
ignoreIntrospection: true,
187+
}),
188+
],
189+
schema,
190+
);
191+
const result = await testkit.execute(query);
192+
193+
assertSingleExecutionValue(result);
194+
expect(result.errors).toBeDefined();
195+
expect(result.errors?.map((error) => error.message)).toEqual([`Syntax Error: ${customMessage}`]);
196+
});
197+
198+
it('rejects with detailed error message when exposeLimits is true', async () => {
199+
const maxCost = 10;
200+
const testkit = createTestkit(
201+
[
202+
costLimitPlugin({
203+
maxCost: maxCost,
204+
exposeLimits: true,
205+
objectCost: 4,
206+
scalarCost: 2,
207+
depthCostFactor: 2,
208+
ignoreIntrospection: true,
209+
}),
210+
],
211+
schema,
212+
);
213+
const result = await testkit.execute(query);
214+
215+
assertSingleExecutionValue(result);
216+
expect(result.errors).toBeDefined();
217+
expect(result.errors?.map((error) => error.message)).toEqual([
218+
`Syntax Error: Query Cost limit of ${maxCost} exceeded, found 12.`,
219+
]);
220+
});
172221
});

packages/plugins/max-aliases/src/index.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,21 @@ import {
1111
ValidationContext,
1212
} from 'graphql';
1313

14-
type MaxAliasesOptions = { n?: number; allowList?: string[] } & GraphQLArmorCallbackConfiguration;
14+
type MaxAliasesOptions = {
15+
n?: number;
16+
allowList?: string[];
17+
exposeLimits?: boolean;
18+
errorMessage?: string;
19+
} & GraphQLArmorCallbackConfiguration;
20+
1521
const maxAliasesDefaultOptions: Required<MaxAliasesOptions> = {
1622
n: 15,
23+
allowList: [],
24+
exposeLimits: true,
25+
errorMessage: 'Query validation error.',
1726
onAccept: [],
1827
onReject: [],
1928
propagateOnRejection: true,
20-
allowList: [],
2129
};
2230

2331
class MaxAliasesVisitor {
@@ -37,14 +45,17 @@ class MaxAliasesVisitor {
3745
this.visitedFragments = new Map();
3846

3947
this.OperationDefinition = {
40-
enter: this.onOperationDefinitionEnter,
48+
enter: this.onOperationDefinitionEnter.bind(this),
4149
};
4250
}
4351

4452
onOperationDefinitionEnter(operation: OperationDefinitionNode): void {
4553
const aliases = this.countAliases(operation);
4654
if (aliases > this.config.n) {
47-
const err = new GraphQLError(`Syntax Error: Aliases limit of ${this.config.n} exceeded, found ${aliases}.`);
55+
const message = this.config.exposeLimits
56+
? `Aliases limit of ${this.config.n} exceeded, found ${aliases}.`
57+
: this.config.errorMessage;
58+
const err = new GraphQLError(`Syntax Error: ${message}`);
4859

4960
for (const handler of this.config.onReject) {
5061
handler(this.context, err);

0 commit comments

Comments
 (0)