From f0145c115a5249680563e490309db528c76edbeb Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Sat, 24 Oct 2026 21:22:51 -0400 Subject: [PATCH] fix(graphql): bound request size and expanded selections --- api/package.json | 1 + .../__test__/app.module.integration.spec.ts | 119 ++++++++++++++ api/src/unraid-api/graph/graph.module.ts | 15 +- .../graph/request-protection.spec.ts | 62 ++++++++ .../unraid-api/graph/request-protection.ts | 43 ++++++ pnpm-lock.yaml | 145 +++++++++++++++++- 6 files changed, 375 insertions(+), 10 deletions(-) create mode 100644 api/src/unraid-api/graph/request-protection.spec.ts create mode 100644 api/src/unraid-api/graph/request-protection.ts diff --git a/api/package.json b/api/package.json index 695fc2094d..16e18ff27d 100644 --- a/api/package.json +++ b/api/package.json @@ -60,6 +60,7 @@ "@apollo/client": "3.14.0", "@apollo/server": "5.5.1", "@as-integrations/fastify": "3.1.0", + "@escape.tech/graphql-armor": "3.2.0", "@fastify/cookie": "11.0.2", "@fastify/helmet": "13.0.1", "@graphql-tools/merge": "9.1.1", diff --git a/api/src/unraid-api/app/__test__/app.module.integration.spec.ts b/api/src/unraid-api/app/__test__/app.module.integration.spec.ts index 3dfa0ae691..3a17896d6c 100644 --- a/api/src/unraid-api/app/__test__/app.module.integration.spec.ts +++ b/api/src/unraid-api/app/__test__/app.module.integration.spec.ts @@ -4,6 +4,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import WebSocket from 'ws'; import { AppModule } from '@app/unraid-api/app/app.module.js'; import { AuthService } from '@app/unraid-api/auth/auth.service.js'; @@ -177,6 +178,124 @@ describe('AppModule Integration Tests', () => { // Either we get data or errors, but the endpoint should respond expect(response.body.data || response.body.errors).toBeDefined(); }); + + it('rejects an amplified GraphQL request before execution', async () => { + const fields = Array.from({ length: 101 }, (_, index) => `a${index}: isSSOEnabled`).join( + ' ' + ); + const response = await request(app.getHttpServer()) + .post('/graphql') + .send({ query: `{ ${fields} }` }); + + expect(response.status).toBe(400); + expect(response.body.data).toBeUndefined(); + expect(response.body.errors).toBeDefined(); + }); + + it('rejects oversized GraphQL documents during parsing', async () => { + const fields = Array.from({ length: 2_501 }, (_, index) => `a${index}: isSSOEnabled`).join( + ' ' + ); + const response = await request(app.getHttpServer()) + .post('/graphql') + .send({ query: `{ ${fields} }` }); + + expect(response.status).toBe(400); + expect(response.body.data).toBeUndefined(); + expect(response.body.errors).toBeDefined(); + }); + + it('applies the same bounds to WebSocket operations', async () => { + await app.listen(0, '127.0.0.1'); + const address = app.getHttpServer().address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP listener'); + const socket = new WebSocket( + `ws://127.0.0.1:${address.port}/graphql`, + 'graphql-transport-ws' + ); + type Message = { type: string; payload?: unknown }; + const messages: Message[] = []; + let waiting: ((message: Message) => void) | undefined; + socket.on('message', (data) => { + const message: Message = JSON.parse(data.toString()); + if (waiting) { + const resolve = waiting; + waiting = undefined; + resolve(message); + } else { + messages.push(message); + } + }); + const receive = () => { + const queued = messages.shift(); + if (queued) return Promise.resolve(queued); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + waiting = undefined; + reject(new Error('WebSocket response timed out')); + }, 5_000); + waiting = (message) => { + clearTimeout(timer); + resolve(message); + }; + }); + }; + + try { + await new Promise((resolve, reject) => { + socket.once('open', () => resolve()); + socket.once('error', reject); + }); + const acknowledgement = receive(); + socket.send(JSON.stringify({ type: 'connection_init' })); + expect((await acknowledgement).type).toBe('connection_ack'); + + const normalResponse = receive(); + socket.send( + JSON.stringify({ + id: 'normal', + type: 'subscribe', + payload: { query: '{ isSSOEnabled }' }, + }) + ); + expect((await normalResponse).type).toBe('next'); + expect((await receive()).type).toBe('complete'); + + const aliases = Array.from( + { length: 101 }, + (_, index) => `a${index}: isSSOEnabled` + ).join(' '); + const aliasResponse = receive(); + socket.send( + JSON.stringify({ + id: 'aliases', + type: 'subscribe', + payload: { query: `{ ${aliases} }` }, + }) + ); + const aliasMessage = await aliasResponse; + expect(aliasMessage.type).toBe('error'); + expect(JSON.stringify(aliasMessage.payload)).toContain('Aliases limit'); + + const tokens = Array.from( + { length: 2_501 }, + (_, index) => `a${index}: isSSOEnabled` + ).join(' '); + const tokenResponse = receive(); + socket.send( + JSON.stringify({ + id: 'tokens', + type: 'subscribe', + payload: { query: `{ ${tokens} }` }, + }) + ); + const tokenMessage = await tokenResponse; + expect(tokenMessage.type).toBe('error'); + expect(JSON.stringify(tokenMessage.payload)).toContain('5000'); + } finally { + socket.terminate(); + } + }, 15_000); }); describe('Service Integration', () => { diff --git a/api/src/unraid-api/graph/graph.module.ts b/api/src/unraid-api/graph/graph.module.ts index cdff7c5976..902505a3a0 100644 --- a/api/src/unraid-api/graph/graph.module.ts +++ b/api/src/unraid-api/graph/graph.module.ts @@ -18,6 +18,11 @@ import { omitIfSchemaTransformer } from '@app/unraid-api/decorators/omit-if.deco import '@app/unraid-api/graph/auth/auth-action.enum.js'; import { createDynamicIntrospectionPlugin } from '@app/unraid-api/graph/introspection-plugin.js'; +import { + graphqlWsOptions, + maxGraphqlTokens, + requestProtection, +} from '@app/unraid-api/graph/request-protection.js'; import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js'; import { createSandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js'; import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js'; @@ -42,6 +47,7 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js'; : true, introspection: true, playground: false, // we handle this in the sandbox plugin + parseOptions: { maxTokens: maxGraphqlTokens }, context: async ({ req, connectionParams, extra }) => { return { req, @@ -51,14 +57,11 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js'; }, fieldResolverEnhancers: ['guards'], plugins: [ + ...requestProtection.plugins, createDynamicIntrospectionPlugin(isSandboxEnabled), createSandboxPlugin(), ] as any[], - subscriptions: { - 'graphql-ws': { - path: '/graphql', - }, - }, + subscriptions: { 'graphql-ws': graphqlWsOptions }, buildSchemaOptions: { dateScalarMode: 'isoDate', // Only add directive when not in test environment to avoid GraphQL version conflicts @@ -72,7 +75,7 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js'; const schemaWithPermissions = usePermissionsSchemaTransformer(schema); return omitIfSchemaTransformer(schemaWithPermissions); }, - validationRules: [NoUnusedVariablesRule], + validationRules: [NoUnusedVariablesRule, ...requestProtection.validationRules], }; }, }), diff --git a/api/src/unraid-api/graph/request-protection.spec.ts b/api/src/unraid-api/graph/request-protection.spec.ts new file mode 100644 index 0000000000..a034f6b4a0 --- /dev/null +++ b/api/src/unraid-api/graph/request-protection.spec.ts @@ -0,0 +1,62 @@ +import { buildSchema, parse, validate } from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { requestProtection } from '@app/unraid-api/graph/request-protection.js'; + +const schema = buildSchema('type Query { value: String, child: Query }'); + +const validateRequest = (query: string) => + validate(schema, parse(query), requestProtection.validationRules); + +describe('requestProtection', () => { + it('allows normal queries and requests at the alias limit', () => { + expect(validateRequest('{ value }')).toHaveLength(0); + + const aliases = Array.from({ length: 100 }, (_, index) => `a${index}: value`).join(' '); + expect(validateRequest(`{ ${aliases} }`)).toHaveLength(0); + }); + + it('rejects requests that exceed the alias limit', () => { + const aliases = Array.from({ length: 101 }, (_, index) => `a${index}: value`).join(' '); + expect(validateRequest(`{ ${aliases} }`).length).toBeGreaterThan(0); + }); + + it('rejects excessive fields without aliases', () => { + const fields = Array.from({ length: 1_001 }, () => 'value').join(' '); + expect(validateRequest(`{ ${fields} }`).length).toBeGreaterThan(0); + }); + + it('counts repeated fragment spreads', () => { + const spreads = Array.from({ length: 1_001 }, () => '...Values').join(' '); + expect( + validateRequest(`{ ${spreads} } fragment Values on Query { value }`).length + ).toBeGreaterThan(0); + }); + + it('counts aliases and fields inside nested fragments', () => { + const spreads = Array.from({ length: 101 }, () => '...Outer').join(' '); + expect( + validateRequest( + `{ ${spreads} } fragment Outer on Query { ...Inner } fragment Inner on Query { alias: value }` + ).length + ).toBeGreaterThan(0); + }); + + it('rejects deeply nested fields', () => { + const nested = `${'child { '.repeat(20)}value${' }'.repeat(20)}`; + expect(validateRequest(`{ ${nested} }`).length).toBeGreaterThan(0); + }); + + it('does not count named fragments as field depth', () => { + const fragments = Array.from( + { length: 21 }, + (_, index) => + `fragment F${index} on Query { ${index === 20 ? 'value' : `...F${index + 1}`} }` + ).join(' '); + expect(validateRequest(`{ ...F0 } ${fragments}`)).toHaveLength(0); + }); + + it('checks each operation separately', () => { + expect(validateRequest('query First { value } query Second { value }')).toHaveLength(0); + }); +}); diff --git a/api/src/unraid-api/graph/request-protection.ts b/api/src/unraid-api/graph/request-protection.ts new file mode 100644 index 0000000000..e1194028fc --- /dev/null +++ b/api/src/unraid-api/graph/request-protection.ts @@ -0,0 +1,43 @@ +import { createRequire } from 'node:module'; + +import type { DocumentNode, GraphQLSchema } from 'graphql'; +import type { SubscribePayload } from 'graphql-ws'; +import { ApolloArmor } from '@escape.tech/graphql-armor'; + +export const maxGraphqlTokens = 5_000; + +const graphqlForWs: typeof import('graphql') = createRequire(import.meta.url)('graphql'); + +export const requestProtection = new ApolloArmor({ + blockFieldSuggestion: { enabled: false }, + costLimit: { + maxCost: 1_000, + objectCost: 1, + scalarCost: 1, + depthCostFactor: 1, + flattenFragments: true, + ignoreIntrospection: false, + }, + maxAliases: { n: 100, allowList: [] }, + maxDepth: { n: 20, flattenFragments: true, ignoreIntrospection: false }, + maxDirectives: { enabled: false }, + maxTokens: { enabled: false }, +}).protect(); + +const websocketValidationRules = [...graphqlForWs.specifiedRules, ...requestProtection.validationRules]; + +export const graphqlWsOptions = { + path: '/graphql', + onSubscribe: (_context: unknown, _id: string, payload: SubscribePayload) => { + try { + graphqlForWs.parse(payload.query, { maxTokens: maxGraphqlTokens }); + } catch (error) { + if (error instanceof graphqlForWs.GraphQLError) return [error]; + throw error; + } + return undefined; + }, + // Nest passes this option to graphql-ws, although its subscription config omits the type. + validate: (schema: GraphQLSchema, document: DocumentNode) => + graphqlForWs.validate(schema, document, websocketValidationRules), +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8175890869..fb013b77a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@as-integrations/fastify': specifier: 3.1.0 version: 3.1.0(@apollo/server@5.5.1(graphql@16.11.0))(fastify@5.12.5) + '@escape.tech/graphql-armor': + specifier: 3.2.0 + version: 3.2.0(@apollo/server@5.5.1(graphql@16.11.0))(@envelop/core@5.6.1)(@escape.tech/graphql-armor-types@0.7.0) '@fastify/cookie': specifier: 11.0.2 version: 11.0.2 @@ -2192,10 +2195,22 @@ packages: resolution: {integrity: sha512-SE3JxL7odst8igN6x77QWyPpXKXz/Hs5o5Y27r+9Br6WHIhkW90lYYVITWIJQ/qYgn5PkpbaVgeFY9rgqQaZ/A==} engines: {node: '>=18.0.0'} + '@envelop/core@5.6.1': + resolution: {integrity: sha512-KEbJ7yrAA+W7IY9MZzpZJ77w/72ndaV/16+hhPapd+0Ohjqffl0mmF3QWy62/m3nRP4qidD+1eiEjJhS/O38Tg==} + engines: {node: '>=18.0.0'} + + '@envelop/instrumentation@1.0.1': + resolution: {integrity: sha512-FuexE2qd3Yc1Kh1DjTliwGxb9kyyv4ySBNnfJOxnQasVmGdcqX6AkkO5sIMxLWFRftp8PErOcIn2dQW/1RwytQ==} + engines: {node: '>=18.0.0'} + '@envelop/types@5.0.0': resolution: {integrity: sha512-IPjmgSc4KpQRlO4qbEDnBEixvtb06WDmjKfi/7fkZaryh5HuOmTtixe1EupQI5XfXO8joc3d27uUZ0QdC++euA==} engines: {node: '>=18.0.0'} + '@envelop/types@5.2.2': + resolution: {integrity: sha512-nl9cmUh8F+2/NNzpcLjzNqtaVn0m8XSzzxrPU2ByHhlz4Hqlc5QBfRsemZkoJMKrMviJXyJYdKZiUP1o70F3Kw==} + engines: {node: '>=18.0.0'} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -2352,6 +2367,48 @@ packages: cpu: [x64] os: [win32] + '@escape.tech/graphql-armor-block-field-suggestions@3.0.1': + resolution: {integrity: sha512-pZ+5aFgGW/pUul7nDOZ3PoeWAd9kLDspQ0R+fpz2aTjdIT0yI+f+ZbAGeTVmr5RypRDwjwokG/HjWoxTYTcRwQ==} + engines: {node: '>=18.0.0'} + + '@escape.tech/graphql-armor-cost-limit@2.4.3': + resolution: {integrity: sha512-fLZTlJjrjinpNhbv5VP6f8Ce4MiQzbcHtCNaCPVaHqArTEbN7vDnlVLiH+VcmZBmOwU6Si97lKdlOXWNgB5ytw==} + engines: {node: '>=18.0.0'} + + '@escape.tech/graphql-armor-max-aliases@2.6.2': + resolution: {integrity: sha512-SDk7pAzY6gutsdZ3NlyY55RrytrCPxJJxSN/DBfIGKphTrfBvKQWTnioQ9OlLP9kPjCE6XM5UWwGt7uqbpKSYA==} + engines: {node: '>=18.0.0'} + + '@escape.tech/graphql-armor-max-depth@2.4.2': + resolution: {integrity: sha512-J9fbW1+W4u3GAcf19wwS0zrNGICCbWn/glvopCoC11Ga0reXvGwgr8EcyuHjTFLL7+pPvWAeVhP4qo6hybcB9w==} + engines: {node: '>=18.0.0'} + + '@escape.tech/graphql-armor-max-directives@2.3.1': + resolution: {integrity: sha512-YieT/NrTgZdLu3hzWfCgpmUKO1o/7Im9C4TI0e4O85Wa+zLit/DzHO4ZCIY//w8nkBf1KStkA1pD/v0daEfY5Q==} + engines: {node: '>=18.0.0'} + + '@escape.tech/graphql-armor-max-tokens@2.5.1': + resolution: {integrity: sha512-XHui2npOz7Jn8shBZqfyeocWhdl0pUbKiaWmvbF+5rvNoRIGMgwMtaVhmf9ia8oGGbd+cx5EYo1v+oKHzIm79w==} + engines: {node: '>=18.0.0'} + + '@escape.tech/graphql-armor-types@0.7.0': + resolution: {integrity: sha512-RHxyyp6PDgS6NAPnnmB6JdmUJ6oqhpSHFbsglGWeCcnNzceA5AkQFpir7VIDbVyS8LNC1xhipOtk7f9ycrIemQ==} + + '@escape.tech/graphql-armor@3.2.0': + resolution: {integrity: sha512-0tG2YOIPiDg2Oe7uEqkYl808VK9OKRyXx4NBa3w+N9XV6NaQ4XxGkDJMtps0lx5DANe10d6nbf6Ml2Z6vekPwA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@apollo/server': ^4.0.0 || ^5.0.0 + '@envelop/core': ^5.0.0 + '@escape.tech/graphql-armor-types': 0.7.0 + peerDependenciesMeta: + '@apollo/server': + optional: true + '@envelop/core': + optional: true + '@escape.tech/graphql-armor-types': + optional: true + '@eslint-community/eslint-utils@4.7.0': resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -11735,8 +11792,8 @@ packages: vue-component-type-helpers@2.2.8: resolution: {integrity: sha512-4bjIsC284coDO9om4HPA62M7wfsTvcmZyzdfR0aUlFXqq4tXxM1APyXpNVxPC8QazKw9OhmZNHBVDA6ODaZsrA==} - vue-component-type-helpers@3.3.12: - resolution: {integrity: sha512-HwCqmgTfLFskQc4P5TRnbduCMyPat2Ezu0elLWOGn0dD/6BKHP15B5bxce6aDi+E4kblPtru8amIBQzu0Di3qQ==} + vue-component-type-helpers@3.3.13: + resolution: {integrity: sha512-02KCxSmKDhIITjKyJ4QT5csP6L0WoRborLqY/34a40+S9uvMvdNgMV0yVjO3p2Gkz3ZoPmFz/0JsGHacwJYTxw==} vue-component-type-helpers@3.3.5: resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==} @@ -13121,10 +13178,30 @@ snapshots: '@envelop/types': 5.0.0 tslib: 2.8.1 + '@envelop/core@5.6.1': + dependencies: + '@envelop/instrumentation': 1.0.1 + '@envelop/types': 5.2.2 + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + optional: true + + '@envelop/instrumentation@1.0.1': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + optional: true + '@envelop/types@5.0.0': dependencies: tslib: 2.8.1 + '@envelop/types@5.2.2': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -13203,6 +13280,66 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@escape.tech/graphql-armor-block-field-suggestions@3.0.1': + dependencies: + graphql: 16.11.0 + optionalDependencies: + '@envelop/core': 5.6.1 + + '@escape.tech/graphql-armor-cost-limit@2.4.3': + dependencies: + graphql: 16.11.0 + optionalDependencies: + '@envelop/core': 5.6.1 + '@escape.tech/graphql-armor-types': 0.7.0 + + '@escape.tech/graphql-armor-max-aliases@2.6.2': + dependencies: + graphql: 16.11.0 + optionalDependencies: + '@envelop/core': 5.6.1 + '@escape.tech/graphql-armor-types': 0.7.0 + + '@escape.tech/graphql-armor-max-depth@2.4.2': + dependencies: + graphql: 16.11.0 + optionalDependencies: + '@envelop/core': 5.6.1 + '@escape.tech/graphql-armor-types': 0.7.0 + + '@escape.tech/graphql-armor-max-directives@2.3.1': + dependencies: + graphql: 16.11.0 + optionalDependencies: + '@envelop/core': 5.6.1 + '@escape.tech/graphql-armor-types': 0.7.0 + + '@escape.tech/graphql-armor-max-tokens@2.5.1': + dependencies: + graphql: 16.11.0 + optionalDependencies: + '@envelop/core': 5.6.1 + '@escape.tech/graphql-armor-types': 0.7.0 + + '@escape.tech/graphql-armor-types@0.7.0': + dependencies: + graphql: 16.11.0 + optional: true + + '@escape.tech/graphql-armor@3.2.0(@apollo/server@5.5.1(graphql@16.11.0))(@envelop/core@5.6.1)(@escape.tech/graphql-armor-types@0.7.0)': + dependencies: + '@escape.tech/graphql-armor-block-field-suggestions': 3.0.1 + '@escape.tech/graphql-armor-cost-limit': 2.4.3 + '@escape.tech/graphql-armor-max-aliases': 2.6.2 + '@escape.tech/graphql-armor-max-depth': 2.4.2 + '@escape.tech/graphql-armor-max-directives': 2.3.1 + '@escape.tech/graphql-armor-max-tokens': 2.5.1 + graphql: 16.11.0 + optionalDependencies: + '@apollo/server': 5.5.1(graphql@16.11.0) + '@envelop/core': 5.6.1 + '@escape.tech/graphql-armor-types': 0.7.0 + '@eslint-community/eslint-utils@4.7.0(eslint@9.34.0(jiti@2.7.0))': dependencies: eslint: 9.34.0(jiti@2.7.0) @@ -15329,7 +15466,7 @@ snapshots: storybook: 9.1.20(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.3.5(@types/node@22.18.0)(jiti@2.7.0)(lightningcss@1.32.0)(stylus@0.64.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.9.0)) type-fest: 2.19.0 vue: 3.5.42(typescript@5.9.2) - vue-component-type-helpers: 3.3.12 + vue-component-type-helpers: 3.3.13 '@swc/core-darwin-arm64@1.13.5': optional: true @@ -23558,7 +23695,7 @@ snapshots: vue-component-type-helpers@2.2.8: {} - vue-component-type-helpers@3.3.12: {} + vue-component-type-helpers@3.3.13: {} vue-component-type-helpers@3.3.5: {}