Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
119 changes: 119 additions & 0 deletions api/src/unraid-api/app/__test__/app.module.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Message>((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<void>((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', () => {
Expand Down
15 changes: 9 additions & 6 deletions api/src/unraid-api/graph/graph.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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],
};
},
}),
Expand Down
62 changes: 62 additions & 0 deletions api/src/unraid-api/graph/request-protection.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
43 changes: 43 additions & 0 deletions api/src/unraid-api/graph/request-protection.ts
Original file line number Diff line number Diff line change
@@ -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),
};
Loading
Loading