diff --git a/api/src/env.ts b/api/src/env.ts index 43275179c..f74b81035 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -30,6 +30,8 @@ const allowedEnvironmentVars = [ 'MAX_BATCH_MUTATION', 'LOGGER_.+', 'ROBOTS_TXT', + 'MAX_RELATIONAL_DEPTH', + 'MAX_ITEMS_PER_QUERY', // server 'SERVER_.+', // database @@ -211,6 +213,7 @@ const defaults: Record = { PUBLIC_URL: '/', MAX_PAYLOAD_SIZE: '1mb', MAX_RELATIONAL_DEPTH: 10, + MAX_ITEMS_PER_QUERY: -1, MAX_BATCH_MUTATION: Infinity, ROBOTS_TXT: 'User-agent: *\nDisallow: /', @@ -333,6 +336,8 @@ const typeMap: Record = { MAX_BATCH_MUTATION: 'number', SERVER_SHUTDOWN_TIMEOUT: 'number', + + MAX_ITEMS_PER_QUERY: 'number', }; let env: Record = { diff --git a/api/src/utils/sanitize-query.test.ts b/api/src/utils/sanitize-query.test.ts index ec59d4e26..1eb17ee9e 100644 --- a/api/src/utils/sanitize-query.test.ts +++ b/api/src/utils/sanitize-query.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import env from '../env.js'; import { sanitizeQuery } from './sanitize-query.js'; vi.mock('@wbce-d9/utils', async () => { @@ -27,6 +28,29 @@ describe('limit', () => { }); }); +describe('limit with MAX_ITEMS_PER_QUERY env', () => { + const originalMaxItemsPerQuery = env['MAX_ITEMS_PER_QUERY']; + + beforeEach(() => { + env['MAX_ITEMS_PER_QUERY'] = 100; + }); + + afterEach(() => { + env['MAX_ITEMS_PER_QUERY'] = originalMaxItemsPerQuery; + }); + + test.each([0, 10, 100])('should accept number %i with env', (limit) => { + const sanitizedQuery = sanitizeQuery({ limit }); + expect(sanitizedQuery.limit).toBe(limit); + }); + + test('should set limit value to 100 with env', () => { + const limit = '-1'; + const sanitizedQuery = sanitizeQuery({ limit }); + expect(sanitizedQuery.limit).toBe(100); + }); +}); + describe('fields', () => { test('should accept valid value', () => { const fields = ['field_a', 'field_b']; diff --git a/api/src/utils/sanitize-query.ts b/api/src/utils/sanitize-query.ts index a1daa8f6d..5ed81641d 100644 --- a/api/src/utils/sanitize-query.ts +++ b/api/src/utils/sanitize-query.ts @@ -1,6 +1,7 @@ import type { Accountability, Aggregate, Filter, Query } from '@wbce-d9/types'; import { parseFilter, parseJSON } from '@wbce-d9/utils'; import { flatten, get, isPlainObject, merge, set } from 'lodash-es'; +import env from '../env.js'; import logger from '../logger.js'; import { Meta } from '../types/index.js'; @@ -8,9 +9,15 @@ export function sanitizeQuery(rawQuery: Record, accountability?: Ac const query: Query = {}; if (rawQuery['limit'] !== undefined) { - const limit = sanitizeLimit(rawQuery['limit']); + let limit = sanitizeLimit(rawQuery['limit']); if (typeof limit === 'number') { + const maxItemsPerQuery = env['MAX_ITEMS_PER_QUERY']; + + if (maxItemsPerQuery !== -1 && limit === -1) { + limit = maxItemsPerQuery; + } + query.limit = limit; } } diff --git a/api/src/utils/validate-query.ts b/api/src/utils/validate-query.ts index e8f0e12f4..ec6cd4bd8 100644 --- a/api/src/utils/validate-query.ts +++ b/api/src/utils/validate-query.ts @@ -33,6 +33,10 @@ export function validateQuery(query: Query): Query { validateAlias(query.alias); } + if (query.limit) { + validateLimit(query.limit); + } + validateRelationalDepth(query); if (error) { @@ -232,3 +236,11 @@ function validateRelationalDepth(query: Query) { } } } + +function validateLimit(limit: any) { + const maxItemsPerQuery = env['MAX_ITEMS_PER_QUERY']; + + if (maxItemsPerQuery !== -1 && limit > maxItemsPerQuery) { + throw new InvalidQueryException(`"Limit" can't exceed ${maxItemsPerQuery}`); + } +} diff --git a/docs/self-hosted/config-options.md b/docs/self-hosted/config-options.md index 2f1489a20..10eb1501f 100644 --- a/docs/self-hosted/config-options.md +++ b/docs/self-hosted/config-options.md @@ -205,6 +205,7 @@ prefixing the value with `{type}:`. The following types are available: | `GRAPHQL_INTROSPECTION` | Whether or not to enable GraphQL Introspection | `true` | | `MAX_BATCH_MUTATION` | The maximum number of items for batch mutations when creating, updating and deleting. | `Infinity` | | `MAX_RELATIONAL_DEPTH` | The maximum depth when filtering / querying relational fields, with a minimum value of `2`. | `10` | +| `MAX_ITEMS_PER_QUERY` | The maximum items when allowed querying relational fields. Set it to `-1` to remove the limitation. | `-1` | | `ROBOTS_TXT` | What the `/robots.txt` endpoint should return | `User-agent: *\nDisallow: /` | | `X_POWERED_BY_ENABLED` | Whether the response should return the X-Powered-By Directus Header | `true` | diff --git a/tests/blackbox/common/config.ts b/tests/blackbox/common/config.ts index e148c4a8e..48048549e 100644 --- a/tests/blackbox/common/config.ts +++ b/tests/blackbox/common/config.ts @@ -78,6 +78,7 @@ const directusConfig = { SERVE_APP: 'false', DB_EXCLUDE_TABLES: 'knex_migrations,knex_migrations_lock,spatial_ref_sys,sysdiagrams', MAX_RELATIONAL_DEPTH: '5', + MAX_ITEMS_PER_QUERY: '-1', MAX_PAYLOAD_SIZE: '10mb', EXTENSIONS_PATH: './extensions', ASSETS_TRANSFORM_MAX_CONCURRENT: '2',