@wbce-d9/api 12.5.11 → 12.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,6 +116,9 @@ export function createSAMLAuthRouter(providerName) {
116
116
  }));
117
117
  router.post('/acs', express.urlencoded({ extended: false }), asyncHandler(async (req, res, next) => {
118
118
  const relayState = req.body?.RelayState;
119
+ if (isRedirectAllowedOnLogin(relayState, providerName) === false) {
120
+ throw new InvalidPayloadException(`URL "${relayState}" can't be used to redirect after login`);
121
+ }
119
122
  try {
120
123
  const { sp, idp } = getAuthProvider(providerName);
121
124
  const { extract } = await sp.parseLoginResponse(idp, 'post', {
package/dist/env.js CHANGED
@@ -61,6 +61,7 @@ const allowedEnvironmentVars = [
61
61
  'IP_CUSTOM_HEADER',
62
62
  'ASSETS_CONTENT_SECURITY_POLICY',
63
63
  'IMPORT_IP_DENY_LIST',
64
+ 'IMPORT_FILE_IP_ALLOW_LIST',
64
65
  'CONTENT_SECURITY_POLICY_.+',
65
66
  'HSTS_.+',
66
67
  'CROSS_ORIGIN_OPENER_POLICY',
@@ -284,6 +285,7 @@ const defaults = {
284
285
  IP_TRUST_PROXY: true,
285
286
  IP_CUSTOM_HEADER: false,
286
287
  IMPORT_IP_DENY_LIST: ['0.0.0.0', '169.254.169.254'],
288
+ IMPORT_FILE_IP_ALLOW_LIST: [],
287
289
  CROSS_ORIGIN_OPENER_POLICY: 'same-origin',
288
290
  SERVE_APP: true,
289
291
  SERVE_GRAPHQL: true,
@@ -308,6 +310,7 @@ const typeMap = {
308
310
  DB_EXCLUDE_TABLES: 'array',
309
311
  CACHE_SKIP_ALLOWED: 'boolean',
310
312
  IMPORT_IP_DENY_LIST: 'array',
313
+ IMPORT_FILE_IP_ALLOW_LIST: 'array',
311
314
  FILE_METADATA_ALLOW_LIST: 'array',
312
315
  SERVE_GRAPHQL: 'boolean',
313
316
  GRAPHQL_INTROSPECTION: 'boolean',
package/dist/flows.d.ts CHANGED
@@ -15,6 +15,11 @@ declare class FlowManager {
15
15
  runOperationFlow(id: string, data: unknown, context: Record<string, unknown>): Promise<unknown>;
16
16
  runWebhookFlow(id: string, data: unknown, context: Record<string, unknown>): Promise<unknown>;
17
17
  private load;
18
+ /**
19
+ * Manual flows act on behalf of the triggering user: make sure that user can see the flow itself
20
+ * and read the collection / items passed in the payload before running it.
21
+ */
22
+ private checkManualTriggerAccess;
18
23
  private unload;
19
24
  private executeFlow;
20
25
  private executeOperation;
package/dist/flows.js CHANGED
@@ -197,13 +197,16 @@ class FlowManager {
197
197
  logger.warn(`Specified collection must be one of: ${enabledCollections.join(', ')}.`);
198
198
  throw new exceptions.ForbiddenException();
199
199
  }
200
- if (flow.options['async']) {
201
- this.executeFlow(flow, data, context);
202
- return undefined;
203
- }
204
- else {
205
- return this.executeFlow(flow, data, context);
206
- }
200
+ const run = () => {
201
+ if (flow.options['async']) {
202
+ this.executeFlow(flow, data, context);
203
+ return undefined;
204
+ }
205
+ else {
206
+ return this.executeFlow(flow, data, context);
207
+ }
208
+ };
209
+ return this.checkManualTriggerAccess(flow, targetCollection, data, context).then(run);
207
210
  };
208
211
  // Default return to $last for manual
209
212
  flow.options['return'] = '$last';
@@ -212,6 +215,34 @@ class FlowManager {
212
215
  }
213
216
  this.isLoaded = true;
214
217
  }
218
+ /**
219
+ * Manual flows act on behalf of the triggering user: make sure that user can see the flow itself
220
+ * and read the collection / items passed in the payload before running it.
221
+ */
222
+ async checkManualTriggerAccess(flow, collection, data, context) {
223
+ const accountability = context['accountability'];
224
+ if (!accountability)
225
+ throw new exceptions.ForbiddenException();
226
+ if (accountability.admin)
227
+ return;
228
+ const database = context['database'] ?? getDatabase();
229
+ const schema = context['schema'] ?? (await getSchema({ database }));
230
+ const flowsService = new FlowsService({ accountability, knex: database, schema });
231
+ await flowsService.readOne(flow.id, { fields: ['id'] });
232
+ const keys = data?.['body']?.keys;
233
+ if (keys === null || keys === undefined || (Array.isArray(keys) && keys.length === 0)) {
234
+ if (flow.options?.['requireSelection'] !== false)
235
+ throw new exceptions.ForbiddenException();
236
+ const canReadCollection = accountability.permissions?.some((permission) => permission.collection === collection && permission.action === 'read');
237
+ if (!canReadCollection)
238
+ throw new exceptions.ForbiddenException();
239
+ return;
240
+ }
241
+ if (!Array.isArray(keys))
242
+ throw new exceptions.ForbiddenException();
243
+ const authorizationService = new services.AuthorizationService({ accountability, knex: database, schema });
244
+ await authorizationService.checkAccess('read', collection, keys);
245
+ }
215
246
  async unload() {
216
247
  for (const trigger of this.triggerHandlers) {
217
248
  trigger.events.forEach((event) => {
@@ -1,5 +1,27 @@
1
+ /// <reference types="node" resolution-mode="require"/>
1
2
  import type { AxiosInstance } from 'axios';
3
+ import { RequestFilteringHttpAgent, RequestFilteringHttpsAgent } from 'request-filtering-agent';
2
4
  export declare const _cache: {
3
5
  axiosInstance: AxiosInstance | null;
4
6
  };
5
7
  export declare function getAxios(): Promise<AxiosInstance>;
8
+ /**
9
+ * Request config for file imports, where the URL is user-provided: only public IP addresses (or those in
10
+ * IMPORT_FILE_IP_ALLOW_LIST) are allowed, checked on every connection (including redirects).
11
+ * request-filtering-agent skips its deny list for addresses in its allow list, so IMPORT_IP_DENY_LIST is checked again
12
+ * on every DNS lookup and redirect to make sure it always takes precedence over IMPORT_FILE_IP_ALLOW_LIST
13
+ */
14
+ export declare const importRequestConfig: () => {
15
+ maxRedirects: number;
16
+ httpAgent: RequestFilteringHttpAgent;
17
+ httpsAgent: RequestFilteringHttpsAgent;
18
+ lookup: (hostname: string, options: object) => Promise<[import("dns").LookupAddress[]]>;
19
+ beforeRedirect: (options: Record<string, any>, responseDetails: {
20
+ headers: Record<string, string>;
21
+ statusCode: import("axios").HttpStatusCode;
22
+ }, requestDetails: {
23
+ headers: Record<string, string>;
24
+ url: string;
25
+ method: string;
26
+ }) => void;
27
+ };
@@ -1,5 +1,10 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { isIP } from 'node:net';
3
+ import { RequestFilteringHttpAgent, RequestFilteringHttpsAgent } from 'request-filtering-agent';
4
+ import { getEnv } from '../env.js';
1
5
  import { requestInterceptor } from './request-interceptor.js';
2
6
  import { responseInterceptor } from './response-interceptor.js';
7
+ import { validateIP } from './validate-ip.js';
3
8
  export const _cache = {
4
9
  axiosInstance: null,
5
10
  };
@@ -12,3 +17,35 @@ export async function getAxios() {
12
17
  }
13
18
  return _cache.axiosInstance;
14
19
  }
20
+ const denyListLookup = async (hostname, options) => {
21
+ const addresses = await lookup(hostname, { ...options, all: true });
22
+ for (const { address } of addresses) {
23
+ validateIP(address, hostname);
24
+ }
25
+ return [addresses];
26
+ };
27
+ // IP addresses in the redirect URL are connected to without DNS lookup
28
+ const denyListBeforeRedirect = ({ hostname, href }) => {
29
+ if (isIP(hostname) !== 0)
30
+ validateIP(hostname, href);
31
+ };
32
+ /**
33
+ * Request config for file imports, where the URL is user-provided: only public IP addresses (or those in
34
+ * IMPORT_FILE_IP_ALLOW_LIST) are allowed, checked on every connection (including redirects).
35
+ * request-filtering-agent skips its deny list for addresses in its allow list, so IMPORT_IP_DENY_LIST is checked again
36
+ * on every DNS lookup and redirect to make sure it always takes precedence over IMPORT_FILE_IP_ALLOW_LIST
37
+ */
38
+ export const importRequestConfig = () => {
39
+ const env = getEnv();
40
+ const options = {
41
+ denyIPAddressList: env['IMPORT_IP_DENY_LIST'],
42
+ allowIPAddressList: env['IMPORT_FILE_IP_ALLOW_LIST'],
43
+ };
44
+ return {
45
+ maxRedirects: 5,
46
+ httpAgent: new RequestFilteringHttpAgent(options),
47
+ httpsAgent: new RequestFilteringHttpsAgent(options),
48
+ lookup: denyListLookup,
49
+ beforeRedirect: denyListBeforeRedirect,
50
+ };
51
+ };
@@ -21,6 +21,6 @@ export const requestInterceptor = async (config) => {
21
21
  else {
22
22
  ip = hostname;
23
23
  }
24
- await validateIP(ip, uri);
24
+ validateIP(ip, uri);
25
25
  return config;
26
26
  };
@@ -1,5 +1,5 @@
1
1
  import { validateIP } from './validate-ip.js';
2
2
  export const responseInterceptor = async (config) => {
3
- await validateIP(config.request.socket.remoteAddress, config.request.url);
3
+ validateIP(config.request.socket.remoteAddress, config.request.url);
4
4
  return config;
5
5
  };
@@ -1 +1 @@
1
- export declare const validateIP: (ip: string, url: string) => Promise<void>;
1
+ export declare const validateIP: (ip: string, url: string) => void;
@@ -1,8 +1,8 @@
1
- import { BlockList } from 'net';
1
+ import { BlockList, isIP } from 'net';
2
2
  import os from 'node:os';
3
3
  import { getEnv } from '../env.js';
4
4
  import logger from '../logger.js';
5
- export const validateIP = async (ip, url) => {
5
+ export const validateIP = (ip, url) => {
6
6
  const env = getEnv();
7
7
  const ipDenyList = env['IMPORT_IP_DENY_LIST'] || [];
8
8
  // Create a BlockList for efficient IP range checking
@@ -53,7 +53,7 @@ export const validateIP = async (ip, url) => {
53
53
  logger.warn(`Warning: Invalid prefix length in CIDR "${info.cidr}"`);
54
54
  continue;
55
55
  }
56
- blockList.addSubnet(network, prefixLength);
56
+ blockList.addSubnet(network, prefixLength, isIP(network) === 6 ? 'ipv6' : 'ipv4');
57
57
  }
58
58
  catch (error) {
59
59
  // Log but don't fail for invalid local interface CIDR
@@ -64,7 +64,7 @@ export const validateIP = async (ip, url) => {
64
64
  }
65
65
  }
66
66
  // Check if the IP is blocked
67
- if (blockList.check(ip)) {
67
+ if (blockList.check(ip, isIP(ip) === 6 ? 'ipv6' : 'ipv4')) {
68
68
  throw new Error(`Requested URL "${url}" resolves to a denied IP address`);
69
69
  }
70
70
  };
@@ -458,6 +458,18 @@ export class FieldsService {
458
458
  if (Object.keys(collectionMetaUpdates).length > 0) {
459
459
  await trx('directus_collections').update(collectionMetaUpdates).where({ collection });
460
460
  }
461
+ const permissions = await trx('directus_permissions')
462
+ .select('id', 'fields')
463
+ .where({ collection });
464
+ for (const permission of permissions) {
465
+ const permittedFields = permission.fields?.split(',') ?? [];
466
+ if (permittedFields.includes(field) === false)
467
+ continue;
468
+ const remainingFields = permittedFields.filter((permittedField) => permittedField !== field);
469
+ await trx('directus_permissions')
470
+ .update({ fields: remainingFields.length > 0 ? remainingFields.join(',') : null })
471
+ .where({ id: permission.id });
472
+ }
461
473
  // Cleanup directus_fields
462
474
  const metaRow = await trx
463
475
  .select('collection', 'field')
@@ -14,7 +14,7 @@ import emitter from '../emitter.js';
14
14
  import env from '../env.js';
15
15
  import { ForbiddenException, InvalidPayloadException, ServiceUnavailableException } from '../exceptions/index.js';
16
16
  import logger from '../logger.js';
17
- import { getAxios } from '../request/index.js';
17
+ import { getAxios, importRequestConfig } from '../request/index.js';
18
18
  import { getStorage } from '../storage/index.js';
19
19
  import { parseIptc, parseXmp } from '../utils/parse-image-metadata.js';
20
20
  import { ItemsService } from './items.js';
@@ -216,6 +216,7 @@ export class FilesService extends ItemsService {
216
216
  const axios = await getAxios();
217
217
  fileResponse = await axios.get(encodeURL(importURL), {
218
218
  responseType: 'stream',
219
+ ...importRequestConfig(),
219
220
  });
220
221
  }
221
222
  catch (err) {
@@ -35,6 +35,9 @@ export class PayloadService {
35
35
  if (action === 'create' || action === 'update') {
36
36
  return await generateHash(String(value));
37
37
  }
38
+ // Same as conceal
39
+ if (action === 'read')
40
+ return '**********';
38
41
  return value;
39
42
  },
40
43
  async uuid({ action, value }) {
@@ -62,7 +62,8 @@ class OASSpecsService {
62
62
  info: {
63
63
  title: 'Dynamic API Specification',
64
64
  description: 'This is a dynamically generated API specification for all endpoints existing on the current project.',
65
- version: version,
65
+ // Only admins see the real version, as on /server/info
66
+ version: this.accountability?.admin ? version : '0.0.0-hidden',
66
67
  },
67
68
  servers: [
68
69
  {
@@ -32,4 +32,4 @@ export declare function applyFilter(knex: Knex, schema: SchemaOverview, rootQuer
32
32
  };
33
33
  export declare function addWhereClauses(knex: Knex, schema: SchemaOverview, dbQuery: Knex.QueryBuilder, filter: Filter, collection: string, aliasMap: AliasMap, logical?: 'and' | 'or'): void;
34
34
  export declare function applySearch(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, searchQuery: string, collection: string): Promise<void>;
35
- export declare function applyAggregate(dbQuery: Knex.QueryBuilder, aggregate: Aggregate, collection: string): void;
35
+ export declare function applyAggregate(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, aggregate: Aggregate, collection: string): void;
@@ -33,7 +33,7 @@ export default function applyQuery(knex, collection, dbQuery, query, schema, opt
33
33
  dbQuery.groupBy(query.group.map((column) => getColumn(knex, collection, column, false, schema)));
34
34
  }
35
35
  if (query.aggregate) {
36
- applyAggregate(dbQuery, query.aggregate, collection);
36
+ applyAggregate(schema, dbQuery, query.aggregate, collection);
37
37
  }
38
38
  if (query.filter) {
39
39
  hasMultiRelationalFilter = applyFilter(knex, schema, dbQuery, query.filter, collection, aliasMap).hasMultiRelationalFilter;
@@ -166,6 +166,9 @@ export function applySort(knex, schema, rootQuery, rootSort, collection, aliasMa
166
166
  rootQuery.orderBy(sortRecords);
167
167
  return undefined;
168
168
  }
169
+ function isConcealedField(schema, collection, field) {
170
+ return schema.collections[collection]?.fields[stripFunction(field)]?.special.includes('conceal') ?? false;
171
+ }
169
172
  export function applyLimit(knex, rootQuery, limit) {
170
173
  if (typeof limit === 'number') {
171
174
  getHelpers(knex).schema.applyLimit(rootQuery, limit);
@@ -480,6 +483,8 @@ export async function applySearch(schema, dbQuery, searchQuery, collection) {
480
483
  const fields = Object.entries(schema.collections[collection].fields);
481
484
  dbQuery.andWhere(function () {
482
485
  fields.forEach(([name, field]) => {
486
+ if (field.special.includes('conceal'))
487
+ return;
483
488
  if (['text', 'string'].includes(field.type)) {
484
489
  this.orWhereRaw(`LOWER(??) LIKE ?`, [`${collection}.${name}`, `%${searchQuery.toLowerCase()}%`]);
485
490
  }
@@ -494,11 +499,14 @@ export async function applySearch(schema, dbQuery, searchQuery, collection) {
494
499
  });
495
500
  });
496
501
  }
497
- export function applyAggregate(dbQuery, aggregate, collection) {
502
+ export function applyAggregate(schema, dbQuery, aggregate, collection) {
498
503
  for (const [operation, fields] of Object.entries(aggregate)) {
499
504
  if (!fields)
500
505
  continue;
501
506
  for (const field of fields) {
507
+ if (!operation.startsWith('count') && isConcealedField(schema, collection, field)) {
508
+ throw new InvalidQueryException(`Field with "conceal" special does not allow the "${operation}" aggregate function`);
509
+ }
502
510
  if (operation === 'avg') {
503
511
  dbQuery.avg(`${collection}.${field}`, { as: `avg->${field}` });
504
512
  }
@@ -1,3 +1,3 @@
1
1
  declare const name: string, version: string;
2
- declare const mainVersion: any;
2
+ declare const mainVersion: string;
3
3
  export { name, version, mainVersion };
@@ -4,6 +4,18 @@ import { fileURLToPath } from 'node:url';
4
4
  const __dirname = dirname(fileURLToPath(import.meta.url));
5
5
  const apiPkgPath = resolve(__dirname, '../../package.json');
6
6
  const { name, version } = JSON.parse(readFileSync(apiPkgPath, 'utf8'));
7
- const mainPkgPath = resolve(dirname(apiPkgPath), '../../../../../../package.json');
8
- const mainVersion = existsSync(mainPkgPath) ? JSON.parse(readFileSync(mainPkgPath, 'utf8')).version : null;
7
+ function pkgVersion(path) {
8
+ try {
9
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')).version ?? null : null;
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ // d9 release version (the bundle, matching the Docker image tag): dev-checkout root, then the
16
+ // installed bundle (published/projen image), then the api version as a last resort.
17
+ const mainVersion = pkgVersion(resolve(dirname(apiPkgPath), '../../../../../../package.json')) ??
18
+ pkgVersion(resolve(process.cwd(), 'node_modules/@wbce-d9/directus9/package.json')) ??
19
+ pkgVersion(resolve(process.cwd(), 'node_modules/directus9/package.json')) ??
20
+ version;
9
21
  export { name, version, mainVersion };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbce-d9/api",
3
- "version": "12.5.11",
3
+ "version": "12.6.1",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -85,6 +85,7 @@
85
85
  "openapi3-ts": "3.2.0",
86
86
  "ora": "6.3.0",
87
87
  "pino": "8.11.0",
88
+ "request-filtering-agent": "3.2.1",
88
89
  "rollup": "3.30.0",
89
90
  "uuid": "14.0.0",
90
91
  "wellknown": "0.5.0",
@@ -143,20 +144,20 @@
143
144
  "tmp-promise": "3.0.3",
144
145
  "tsx": "3.12.6",
145
146
  "uuid-validate": "0.0.3",
147
+ "@wbce-d9/app": "11.1.0",
146
148
  "@wbce-d9/constants": "10.0.0",
147
- "@wbce-d9/app": "11.0.0",
148
- "@wbce-d9/exceptions": "10.0.0",
149
- "@wbce-d9/specs": "9.26.5",
149
+ "@wbce-d9/exceptions": "10.0.1",
150
150
  "@wbce-d9/schema": "10.0.0",
151
151
  "@wbce-d9/storage": "10.0.0",
152
- "@wbce-d9/storage-driver-gcs": "10.0.3",
153
- "@wbce-d9/storage-driver-cloudinary": "10.0.4",
154
- "@wbce-d9/storage-driver-azure": "10.0.3",
155
- "@wbce-d9/storage-driver-local": "10.0.3",
156
- "@wbce-d9/storage-driver-s3": "10.0.3",
157
- "@wbce-d9/extensions-sdk": "10.0.4",
152
+ "@wbce-d9/specs": "9.26.5",
153
+ "@wbce-d9/storage-driver-azure": "10.0.4",
154
+ "@wbce-d9/storage-driver-cloudinary": "10.0.5",
155
+ "@wbce-d9/extensions-sdk": "10.0.5",
156
+ "@wbce-d9/storage-driver-local": "10.0.4",
157
+ "@wbce-d9/storage-driver-gcs": "10.0.4",
158
+ "@wbce-d9/storage-driver-s3": "10.0.4",
158
159
  "@wbce-d9/update-check": "10.0.4",
159
- "@wbce-d9/utils": "10.0.3"
160
+ "@wbce-d9/utils": "10.0.4"
160
161
  },
161
162
  "devDependencies": {
162
163
  "@ngneat/falso": "6.4.0",