Skip to content

Commit 3528192

Browse files
authored
feat(wobe-graphql-yoga): add options to improve security (#58)
1 parent b822810 commit 3528192

2 files changed

Lines changed: 596 additions & 3 deletions

File tree

packages/wobe-graphql-yoga/src/index.test.ts

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { createSchema } from 'graphql-yoga'
44
import getPort from 'get-port'
55
import { WobeGraphqlYogaPlugin } from '.'
66

7+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
8+
79
describe('Wobe GraphQL Yoga plugin', () => {
810
it('should reject GET requests by default', async () => {
911
const port = await getPort()
@@ -164,6 +166,341 @@ describe('Wobe GraphQL Yoga plugin', () => {
164166
wobe.stop()
165167
})
166168

169+
it('should block queries that exceed max depth', async () => {
170+
const port = await getPort()
171+
const wobe = new Wobe()
172+
173+
await wobe.usePlugin(
174+
WobeGraphqlYogaPlugin({
175+
maxDepth: 2,
176+
typeDefs: `
177+
type Query {
178+
hello: Hello
179+
}
180+
181+
type Hello {
182+
nested: Nested
183+
}
184+
185+
type Nested {
186+
value: String
187+
}
188+
`,
189+
resolvers: {
190+
Query: {
191+
hello: () => ({ nested: { value: 'ok' } }),
192+
},
193+
},
194+
}),
195+
)
196+
197+
wobe.listen(port)
198+
199+
const res = await fetch(`http://127.0.0.1:${port}/graphql`, {
200+
method: 'POST',
201+
headers: { 'Content-Type': 'application/json' },
202+
body: JSON.stringify({
203+
query: `
204+
query TooDeep {
205+
hello { nested { value } }
206+
}
207+
`,
208+
}),
209+
})
210+
211+
const body = await res.json()
212+
expect(body.data).toBeUndefined()
213+
expect(body.errors?.[0]?.message).toContain('max depth')
214+
215+
wobe.stop()
216+
})
217+
218+
it('should block queries that exceed max cost', async () => {
219+
const port = await getPort()
220+
const wobe = new Wobe()
221+
222+
await wobe.usePlugin(
223+
WobeGraphqlYogaPlugin({
224+
maxCost: 2,
225+
typeDefs: `
226+
type Query {
227+
a: String
228+
b: String
229+
c: String
230+
}
231+
`,
232+
resolvers: {
233+
Query: {
234+
a: () => 'a',
235+
b: () => 'b',
236+
c: () => 'c',
237+
},
238+
},
239+
}),
240+
)
241+
242+
wobe.listen(port)
243+
244+
const res = await fetch(`http://127.0.0.1:${port}/graphql`, {
245+
method: 'POST',
246+
headers: { 'Content-Type': 'application/json' },
247+
body: JSON.stringify({
248+
query: `
249+
query TooExpensive {
250+
a
251+
b
252+
c
253+
}
254+
`,
255+
}),
256+
})
257+
258+
const body = await res.json()
259+
expect(body.data).toBeUndefined()
260+
expect(body.errors?.[0]?.message).toContain('too expensive')
261+
262+
wobe.stop()
263+
})
264+
265+
it('should reject multiple operations by default', async () => {
266+
const port = await getPort()
267+
const wobe = new Wobe()
268+
269+
await wobe.usePlugin(
270+
WobeGraphqlYogaPlugin({
271+
typeDefs: `
272+
type Query {
273+
a: String
274+
b: String
275+
}
276+
`,
277+
resolvers: {
278+
Query: {
279+
a: () => 'a',
280+
b: () => 'b',
281+
},
282+
},
283+
}),
284+
)
285+
286+
wobe.listen(port)
287+
288+
const res = await fetch(`http://127.0.0.1:${port}/graphql`, {
289+
method: 'POST',
290+
headers: { 'Content-Type': 'application/json' },
291+
body: JSON.stringify({
292+
query: `
293+
query One { a }
294+
query Two { b }
295+
`,
296+
}),
297+
})
298+
299+
const body = await res.json()
300+
expect(body.data).toBeUndefined()
301+
expect(body.errors?.[0]?.message).toMatch(
302+
/Multiple operations|Could not determine/i,
303+
)
304+
305+
wobe.stop()
306+
})
307+
308+
it('should allow only whitelisted operation names when provided', async () => {
309+
const port = await getPort()
310+
const wobe = new Wobe()
311+
312+
await wobe.usePlugin(
313+
WobeGraphqlYogaPlugin({
314+
allowedOperationNames: ['AllowedOp'],
315+
allowMultipleOperations: false,
316+
typeDefs: `
317+
type Query {
318+
a: String
319+
}
320+
`,
321+
resolvers: {
322+
Query: {
323+
a: () => 'a',
324+
},
325+
},
326+
}),
327+
)
328+
329+
wobe.listen(port)
330+
331+
const res = await fetch(`http://127.0.0.1:${port}/graphql`, {
332+
method: 'POST',
333+
headers: { 'Content-Type': 'application/json' },
334+
body: JSON.stringify({
335+
query: `
336+
query NotAllowed { a }
337+
`,
338+
}),
339+
})
340+
341+
const body = await res.json()
342+
expect(body.data).toBeUndefined()
343+
expect(body.errors?.[0]?.message).toContain('not allowed')
344+
345+
wobe.stop()
346+
})
347+
348+
it('should reject requests that exceed maxRequestSizeBytes', async () => {
349+
const port = await getPort()
350+
const wobe = new Wobe()
351+
352+
await wobe.usePlugin(
353+
WobeGraphqlYogaPlugin({
354+
maxRequestSizeBytes: 10,
355+
typeDefs: `
356+
type Query {
357+
a: String
358+
}
359+
`,
360+
resolvers: {
361+
Query: {
362+
a: () => 'a',
363+
},
364+
},
365+
}),
366+
)
367+
368+
wobe.listen(port)
369+
370+
const res = await fetch(`http://127.0.0.1:${port}/graphql`, {
371+
method: 'POST',
372+
headers: { 'Content-Type': 'application/json' },
373+
body: JSON.stringify({
374+
query: `
375+
query LargePayload { a }
376+
`,
377+
}),
378+
})
379+
380+
expect(res.status).toBe(413)
381+
wobe.stop()
382+
})
383+
384+
it('should timeout when resolver exceeds timeoutMs', async () => {
385+
const port = await getPort()
386+
const wobe = new Wobe()
387+
388+
await wobe.usePlugin(
389+
WobeGraphqlYogaPlugin({
390+
timeoutMs: 10,
391+
typeDefs: `
392+
type Query {
393+
slow: String
394+
}
395+
`,
396+
resolvers: {
397+
Query: {
398+
slow: async () => {
399+
await sleep(50)
400+
return 'slow'
401+
},
402+
},
403+
},
404+
}),
405+
)
406+
407+
wobe.listen(port)
408+
409+
const res = await fetch(`http://127.0.0.1:${port}/graphql`, {
410+
method: 'POST',
411+
headers: { 'Content-Type': 'application/json' },
412+
body: JSON.stringify({
413+
query: `
414+
query Slow { slow }
415+
`,
416+
}),
417+
})
418+
419+
expect(res.status).toBe(504)
420+
wobe.stop()
421+
})
422+
423+
it('should allow rateLimiter to block requests', async () => {
424+
const port = await getPort()
425+
const wobe = new Wobe()
426+
427+
await wobe.usePlugin(
428+
WobeGraphqlYogaPlugin({
429+
rateLimiter: async () =>
430+
new Response('Too Many Requests', { status: 429 }),
431+
typeDefs: `
432+
type Query {
433+
hello: String
434+
}
435+
`,
436+
resolvers: {
437+
Query: {
438+
hello: () => 'Hello',
439+
},
440+
},
441+
}),
442+
)
443+
444+
wobe.listen(port)
445+
446+
const res = await fetch(`http://127.0.0.1:${port}/graphql`, {
447+
method: 'POST',
448+
headers: { 'Content-Type': 'application/json' },
449+
body: JSON.stringify({
450+
query: `
451+
query Test { hello }
452+
`,
453+
}),
454+
})
455+
456+
expect(res.status).toBe(429)
457+
wobe.stop()
458+
})
459+
460+
it('should call onRequestResolved hook with timing info', async () => {
461+
const port = await getPort()
462+
const wobe = new Wobe()
463+
let called = false
464+
let status: number | undefined
465+
466+
await wobe.usePlugin(
467+
WobeGraphqlYogaPlugin({
468+
onRequestResolved: (input) => {
469+
called = true
470+
status = input.status
471+
},
472+
typeDefs: `
473+
type Query {
474+
hello: String
475+
}
476+
`,
477+
resolvers: {
478+
Query: {
479+
hello: () => 'Hello',
480+
},
481+
},
482+
}),
483+
)
484+
485+
wobe.listen(port)
486+
487+
const res = await fetch(`http://127.0.0.1:${port}/graphql`, {
488+
method: 'POST',
489+
headers: { 'Content-Type': 'application/json' },
490+
body: JSON.stringify({
491+
query: `
492+
query Hook { hello }
493+
`,
494+
}),
495+
})
496+
497+
expect(res.status).toBe(200)
498+
expect(called).toBe(true)
499+
expect(status).toBe(200)
500+
501+
wobe.stop()
502+
})
503+
167504
it('should set the wobe response in the graphql context with record', async () => {
168505
const port = await getPort()
169506
const wobe = new Wobe<{ customType: string }>().beforeHandler((ctx) => {

0 commit comments

Comments
 (0)