Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"disposition": "PATCH",
"forecast_commit": "ee919505185a59be4ac687f6452d0db18520be50",
"reviewed_sha": "d7f2783a69c21fa3ccab91a6dc78f73d5823381a",
"schema": "limetech.ai-review-marker.v2",
"stage": "final",
"unresolved_proportionality_findings": []
}
113 changes: 113 additions & 0 deletions api/src/unraid-api/auth/fastify-throttler.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Reflector } from '@nestjs/core';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host.js';
import { ThrottlerStorageService } from '@nestjs/throttler';
import { mkdtemp, rm } from 'node:fs/promises';
import http from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import Fastify from 'fastify';
import { afterEach, describe, expect, it } from 'vitest';

import { FastifyThrottlerGuard } from '@app/unraid-api/auth/fastify-throttler.guard.js';

describe('FastifyThrottlerGuard client identity', () => {
const cleanups: Array<() => Promise<void> | void> = [];
afterEach(async () => {
for (const cleanup of cleanups.reverse()) await cleanup();
cleanups.length = 0;
});

const createServer = async () => {
const storage = new ThrottlerStorageService();
cleanups.push(() => storage.onApplicationShutdown());
const guard = new FastifyThrottlerGuard([{ ttl: 60_000, limit: 2 }], storage, new Reflector());
await guard.onModuleInit();
const server = Fastify({ trustProxy: 'loopback' });
cleanups.push(() => server.close());
class Controller {
authorize() {}
callback() {}
}
for (const path of ['/authorize', '/callback']) {
const handler =
path === '/authorize' ? Controller.prototype.authorize : Controller.prototype.callback;
server.get(path, async (req, res) => {
const context = new ExecutionContextHost([req, res], Controller, handler);
try {
await guard.canActivate(context);
return res.code(204).send();
} catch (error) {
const status =
error instanceof Error &&
'getStatus' in error &&
typeof error.getStatus === 'function'
? error.getStatus()
: 500;
return res.code(status).send();
}
});
}
return server;
};

it('uses the actual TCP peer even when loopback clients forge forwarded headers', async () => {
const server = await createServer();
for (const forwarded of ['192.0.2.1', '192.0.2.2']) {
expect(
(
await server.inject({
url: '/authorize',
remoteAddress: '127.0.0.2',
headers: { 'x-forwarded-for': forwarded },
})
).statusCode
).toBe(204);
}
expect(
(
await server.inject({
url: '/authorize',
remoteAddress: '127.0.0.2',
headers: { 'x-forwarded-for': '192.0.2.3' },
})
).statusCode
).toBe(429);
expect((await server.inject({ url: '/authorize', remoteAddress: '127.0.0.3' })).statusCode).toBe(
204
);
expect((await server.inject({ url: '/callback', remoteAddress: '127.0.0.2' })).statusCode).toBe(
204
);
});

it('keeps Unix socket clients independent and rejects missing or ambiguous identity', async () => {
const directory = await mkdtemp(join(tmpdir(), 'api-ip-'));
cleanups.push(() => rm(directory, { recursive: true, force: true }));
const server = await createServer();
const socketPath = join(directory, 'api.sock');
await server.listen({ path: socketPath });
const send = (address?: string) =>
new Promise<number | undefined>((resolve, reject) => {
const request = http.get(
{
socketPath,
path: '/authorize',
headers: address ? { 'x-forwarded-for': address } : {},
},
(response) => {
response.resume();
response.on('end', () => resolve(response.statusCode));
}
);
request.on('error', reject);
});
expect(await send('192.0.2.1')).toBe(204);
expect(await send('192.0.2.1')).toBe(204);
expect(await send('192.0.2.1')).toBe(429);
expect(await send('192.0.2.2')).toBe(204);
expect(await send()).toBe(400);
expect(await send('192.0.2.1, 192.0.2.2')).toBe(400);
expect(await send('invalid')).toBe(400);
});
});
17 changes: 13 additions & 4 deletions api/src/unraid-api/auth/fastify-throttler.guard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ExecutionContext, Inject, Injectable, Optional } from '@nestjs/common';
import { BadRequestException, ExecutionContext, Inject, Injectable, Optional } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';
import {
Expand All @@ -9,6 +9,7 @@ import {
ThrottlerStorage,
ThrottlerStorageService,
} from '@nestjs/throttler';
import { isIP } from 'node:net';

import { type FastifyRequest } from 'fastify';

Expand All @@ -26,9 +27,17 @@ export class FastifyThrottlerGuard extends ThrottlerGuard {
);
}

protected async getTracker(req: Record<string, any>): Promise<string> {
const request = req as unknown as FastifyRequest;
return request.ip ?? request.ips?.[0] ?? request.headers?.['x-forwarded-for'] ?? '0.0.0.0';
protected async getTracker(request: FastifyRequest): Promise<string> {
const peerAddress = request.raw.socket.remoteAddress;
if (peerAddress) return peerAddress;

// Unix sockets have no peer IP. nginx overwrites this header with its TCP client address.
const forwardedAddress = request.headers['x-forwarded-for'];
if (typeof forwardedAddress === 'string' && isIP(forwardedAddress)) {
return forwardedAddress;
}

throw new BadRequestException('Client IP address is unavailable');
}

getRequestResponse(context: ExecutionContext) {
Expand Down
4 changes: 2 additions & 2 deletions api/src/unraid-api/graph/graph.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import '@app/unraid-api/graph/auth/auth-action.enum.js';

import { createDynamicIntrospectionPlugin } from '@app/unraid-api/graph/introspection-plugin.js';
import {
graphqlWsOptions,
createGraphqlWsOptions,
maxGraphqlTokens,
requestProtection,
} from '@app/unraid-api/graph/request-protection.js';
Expand Down Expand Up @@ -61,7 +61,7 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
createDynamicIntrospectionPlugin(isSandboxEnabled),
createSandboxPlugin(),
] as any[],
subscriptions: { 'graphql-ws': graphqlWsOptions },
subscriptions: { 'graphql-ws': createGraphqlWsOptions(isSandboxEnabled) },
buildSchemaOptions: {
dateScalarMode: 'isoDate',
// Only add directive when not in test environment to avoid GraphQL version conflicts
Expand Down
2 changes: 1 addition & 1 deletion api/src/unraid-api/graph/introspection-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const hasBlockedIntrospectionField = (
return blocked;
});

const isBlockedIntrospectionOperation = (
export const isBlockedIntrospectionOperation = (
operation: OperationDefinitionNode,
document: DocumentNode
): boolean => {
Expand Down
81 changes: 80 additions & 1 deletion api/src/unraid-api/graph/request-protection.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { createServer } from 'node:http';
import { createRequire } from 'node:module';

import { buildSchema, parse, validate } from 'graphql';
import { createClient } from 'graphql-ws';
import { useServer } from 'graphql-ws/use/ws';
import { describe, expect, it } from 'vitest';
import WebSocket, { WebSocketServer } from 'ws';

import { requestProtection } from '@app/unraid-api/graph/request-protection.js';
import { createGraphqlWsOptions, requestProtection } from '@app/unraid-api/graph/request-protection.js';

const schema = buildSchema('type Query { value: String, child: Query }');

Expand Down Expand Up @@ -60,3 +66,76 @@ describe('requestProtection', () => {
expect(validateRequest('query First { value } query Second { value }')).toHaveLength(0);
});
});

describe('WebSocket introspection policy', () => {
it('checks the selected operation before execution and reads sandbox changes on the same connection', async () => {
const graphql: typeof import('graphql') = createRequire(import.meta.url)('graphql');
const socketSchema = graphql.buildSchema('type Query { value: String }');
let resolverCalls = 0;
let sandboxEnabled = false;
socketSchema.getQueryType()!.getFields().value.resolve = () => {
resolverCalls++;
return 'ok';
};
const server = createServer();
const sockets = new WebSocketServer({ server, path: '/graphql' });
const disposable = useServer(
{
schema: socketSchema,
...createGraphqlWsOptions(() => sandboxEnabled),
},
sockets
);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Expected TCP listener');
const client = createClient({
url: `ws://127.0.0.1:${address.port}/graphql`,
webSocketImpl: WebSocket,
lazy: false,
retryAttempts: 0,
});
const run = async (query: string, operationName?: string) => {
const iterator = client.iterate({ query, operationName });
try {
return (await iterator.next()).value;
} finally {
await iterator.return?.();
}
};
try {
for (const query of [
'{ __schema { queryType { name } } value }',
'{ metadata: __type(name: "Query") { name } value }',
'query { ...Metadata value } fragment Metadata on Query { __schema { queryType { name } } }',
'{ ... on Query { __schema @include(if: true) { queryType { name } } } value }',
]) {
await expect(run(query)).rejects.toEqual([
expect.objectContaining({ extensions: { code: 'INTROSPECTION_DISABLED' } }),
]);
}
expect(resolverCalls).toBe(0);
const operations =
'query Normal { value __typename } query Metadata { __schema { queryType { name } } }';
expect((await run(operations, 'Normal'))?.data).toEqual({
value: 'ok',
__typename: 'Query',
});
await expect(run(operations, 'Metadata')).rejects.toBeDefined();
sandboxEnabled = true;
expect((await run(operations, 'Metadata'))?.data).toEqual({
__schema: { queryType: { name: 'Query' } },
});
sandboxEnabled = false;
await expect(run(operations, 'Metadata')).rejects.toBeDefined();
const aliases = Array.from({ length: 101 }, (_, index) => `a${index}: value`).join(' ');
await expect(run(`{ ${aliases} }`)).rejects.toBeDefined();
} finally {
await client.dispose();
await disposable.dispose();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
);
}
});
});
21 changes: 18 additions & 3 deletions api/src/unraid-api/graph/request-protection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { DocumentNode, GraphQLSchema } from 'graphql';
import type { SubscribePayload } from 'graphql-ws';
import { ApolloArmor } from '@escape.tech/graphql-armor';

import { isBlockedIntrospectionOperation } from '@app/unraid-api/graph/introspection-plugin.js';

export const maxGraphqlTokens = 5_000;

const graphqlForWs: typeof import('graphql') = createRequire(import.meta.url)('graphql');
Expand All @@ -26,11 +28,24 @@ export const requestProtection = new ApolloArmor({

const websocketValidationRules = [...graphqlForWs.specifiedRules, ...requestProtection.validationRules];

export const graphqlWsOptions = {
export const createGraphqlWsOptions = (isSandboxEnabled: () => boolean) => ({
path: '/graphql',
onSubscribe: (_context: unknown, _id: string, payload: SubscribePayload) => {
try {
graphqlForWs.parse(payload.query, { maxTokens: maxGraphqlTokens });
const document = graphqlForWs.parse(payload.query, { maxTokens: maxGraphqlTokens });
const operation = graphqlForWs.getOperationAST(document, payload.operationName);
if (
!isSandboxEnabled() &&
operation &&
isBlockedIntrospectionOperation(operation, document)
) {
return [
new graphqlForWs.GraphQLError(
'GraphQL introspection is not allowed, but the current request is for introspection.',
{ extensions: { code: 'INTROSPECTION_DISABLED' } }
),
];
}
} catch (error) {
if (error instanceof graphqlForWs.GraphQLError) return [error];
throw error;
Expand All @@ -40,4 +55,4 @@ export const graphqlWsOptions = {
// 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