From c57d99a1ea40d444ec8e54a86835c8c9cdb9394f Mon Sep 17 00:00:00 2001 From: LOIC Date: Fri, 21 Nov 2025 15:30:34 +0100 Subject: [PATCH 1/6] feat: add MAX_ITEMS_PER_QUERY --- api/src/env.ts | 3 +++ api/src/utils/sanitize-query.ts | 9 ++++++++- api/src/utils/validate-query.ts | 14 ++++++++++++++ docs/self-hosted/config-options.md | 1 + tests/blackbox/common/config.ts | 1 + 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/api/src/env.ts b/api/src/env.ts index 43275179c..14de89f9c 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: 1000, MAX_BATCH_MUTATION: Infinity, ROBOTS_TXT: 'User-agent: *\nDisallow: /', diff --git a/api/src/utils/sanitize-query.ts b/api/src/utils/sanitize-query.ts index a1daa8f6d..b30f7daf1 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 = Number(env['MAX_ITEMS_PER_QUERY']); + if(maxItemsPerQuery !== -1){ + if (limit > maxItemsPerQuery || 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..37ef7c45c 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,13 @@ function validateRelationalDepth(query: Query) { } } } + +function validateLimit(limit: any) { + const maxItemsPerQuery = Number(env['MAX_ITEMS_PER_QUERY']) || 1000; + + if (limit !== -1 && maxItemsPerQuery !== -1) { + if (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..5b9a50c3a 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. | `1000` | | `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..ce5f0d141 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: '1000', MAX_PAYLOAD_SIZE: '10mb', EXTENSIONS_PATH: './extensions', ASSETS_TRANSFORM_MAX_CONCURRENT: '2', From 599b765a607808819a363bdeafcf85c8960aaaf9 Mon Sep 17 00:00:00 2001 From: LOIC Date: Tue, 25 Nov 2025 14:19:53 +0100 Subject: [PATCH 2/6] fix: apply linter --- api/src/utils/sanitize-query.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/src/utils/sanitize-query.ts b/api/src/utils/sanitize-query.ts index b30f7daf1..ead95c3cc 100644 --- a/api/src/utils/sanitize-query.ts +++ b/api/src/utils/sanitize-query.ts @@ -13,11 +13,13 @@ export function sanitizeQuery(rawQuery: Record, accountability?: Ac if (typeof limit === 'number') { const maxItemsPerQuery = Number(env['MAX_ITEMS_PER_QUERY']); - if(maxItemsPerQuery !== -1){ + + if (maxItemsPerQuery !== -1) { if (limit > maxItemsPerQuery || limit === -1) { limit = maxItemsPerQuery; } } + query.limit = limit; } } From a8ca2efe778901e6654b8ad73cfb5474581fd0c5 Mon Sep 17 00:00:00 2001 From: LOIC Date: Wed, 26 Nov 2025 14:22:56 +0100 Subject: [PATCH 3/6] fix: sanitize limit --- api/src/utils/sanitize-query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/utils/sanitize-query.ts b/api/src/utils/sanitize-query.ts index ead95c3cc..4b6da9f6b 100644 --- a/api/src/utils/sanitize-query.ts +++ b/api/src/utils/sanitize-query.ts @@ -15,7 +15,7 @@ export function sanitizeQuery(rawQuery: Record, accountability?: Ac const maxItemsPerQuery = Number(env['MAX_ITEMS_PER_QUERY']); if (maxItemsPerQuery !== -1) { - if (limit > maxItemsPerQuery || limit === -1) { + if (limit === -1) { limit = maxItemsPerQuery; } } From a509ea07d8727727f93ff3da48fbf20f1a710e95 Mon Sep 17 00:00:00 2001 From: LOIC Date: Wed, 3 Dec 2025 12:10:30 +0100 Subject: [PATCH 4/6] chore: cleaning code & improvement --- api/src/env.ts | 2 ++ api/src/utils/sanitize-query.ts | 6 ++---- api/src/utils/validate-query.ts | 8 +++----- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/api/src/env.ts b/api/src/env.ts index 14de89f9c..ea705952d 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -336,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.ts b/api/src/utils/sanitize-query.ts index 4b6da9f6b..472af8309 100644 --- a/api/src/utils/sanitize-query.ts +++ b/api/src/utils/sanitize-query.ts @@ -14,10 +14,8 @@ export function sanitizeQuery(rawQuery: Record, accountability?: Ac if (typeof limit === 'number') { const maxItemsPerQuery = Number(env['MAX_ITEMS_PER_QUERY']); - if (maxItemsPerQuery !== -1) { - if (limit === -1) { - limit = maxItemsPerQuery; - } + 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 37ef7c45c..ec6cd4bd8 100644 --- a/api/src/utils/validate-query.ts +++ b/api/src/utils/validate-query.ts @@ -238,11 +238,9 @@ function validateRelationalDepth(query: Query) { } function validateLimit(limit: any) { - const maxItemsPerQuery = Number(env['MAX_ITEMS_PER_QUERY']) || 1000; + const maxItemsPerQuery = env['MAX_ITEMS_PER_QUERY']; - if (limit !== -1 && maxItemsPerQuery !== -1) { - if (limit > maxItemsPerQuery) { - throw new InvalidQueryException(`"Limit" can't exceed ${maxItemsPerQuery}`); - } + if (maxItemsPerQuery !== -1 && limit > maxItemsPerQuery) { + throw new InvalidQueryException(`"Limit" can't exceed ${maxItemsPerQuery}`); } } From ae2a5406026ef11b627b63bc017c65f99d16049d Mon Sep 17 00:00:00 2001 From: LOIC Date: Thu, 4 Dec 2025 10:02:24 +0100 Subject: [PATCH 5/6] chore: change default value & add unit test --- api/src/env.ts | 2 +- api/src/utils/sanitize-query.test.ts | 26 +++++++++++++++++++++++++- docs/self-hosted/config-options.md | 2 +- tests/blackbox/common/config.ts | 2 +- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/api/src/env.ts b/api/src/env.ts index ea705952d..f74b81035 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -213,7 +213,7 @@ const defaults: Record = { PUBLIC_URL: '/', MAX_PAYLOAD_SIZE: '1mb', MAX_RELATIONAL_DEPTH: 10, - MAX_ITEMS_PER_QUERY: 1000, + MAX_ITEMS_PER_QUERY: -1, MAX_BATCH_MUTATION: Infinity, ROBOTS_TXT: 'User-agent: *\nDisallow: /', 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/docs/self-hosted/config-options.md b/docs/self-hosted/config-options.md index 5b9a50c3a..10eb1501f 100644 --- a/docs/self-hosted/config-options.md +++ b/docs/self-hosted/config-options.md @@ -205,7 +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. | `1000` | +| `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 ce5f0d141..48048549e 100644 --- a/tests/blackbox/common/config.ts +++ b/tests/blackbox/common/config.ts @@ -78,7 +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: '1000', + MAX_ITEMS_PER_QUERY: '-1', MAX_PAYLOAD_SIZE: '10mb', EXTENSIONS_PATH: './extensions', ASSETS_TRANSFORM_MAX_CONCURRENT: '2', From 97ce4fc339efe8889dedc0ecaa0545931138f7a2 Mon Sep 17 00:00:00 2001 From: LOIC Date: Mon, 8 Dec 2025 14:28:03 +0100 Subject: [PATCH 6/6] fix: remove Number constructor --- api/src/utils/sanitize-query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/utils/sanitize-query.ts b/api/src/utils/sanitize-query.ts index 472af8309..5ed81641d 100644 --- a/api/src/utils/sanitize-query.ts +++ b/api/src/utils/sanitize-query.ts @@ -12,7 +12,7 @@ export function sanitizeQuery(rawQuery: Record, accountability?: Ac let limit = sanitizeLimit(rawQuery['limit']); if (typeof limit === 'number') { - const maxItemsPerQuery = Number(env['MAX_ITEMS_PER_QUERY']); + const maxItemsPerQuery = env['MAX_ITEMS_PER_QUERY']; if (maxItemsPerQuery !== -1 && limit === -1) { limit = maxItemsPerQuery;