@wbce-d9/api 12.5.10 → 12.6.0
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.
- package/dist/app.js +1 -0
- package/dist/auth/drivers/saml.js +3 -0
- package/dist/cli/utils/create-env/env-stub.liquid +3 -0
- package/dist/env.js +5 -0
- package/dist/operations/condition/index.js +3 -1
- package/dist/request/index.d.ts +22 -0
- package/dist/request/index.js +37 -0
- package/dist/request/request-interceptor.js +1 -1
- package/dist/request/response-interceptor.js +1 -1
- package/dist/request/validate-ip.d.ts +1 -1
- package/dist/request/validate-ip.js +4 -4
- package/dist/services/files.js +2 -1
- package/dist/utils/is-redirect-allowed-on-login.js +13 -14
- package/package.json +12 -11
package/dist/app.js
CHANGED
|
@@ -109,6 +109,7 @@ export default async function createApp() {
|
|
|
109
109
|
if (env['HSTS_ENABLED']) {
|
|
110
110
|
app.use(helmet.hsts(getConfigFromEnv('HSTS_', ['HSTS_ENABLED'])));
|
|
111
111
|
}
|
|
112
|
+
app.use(helmet.crossOriginOpenerPolicy({ policy: env['CROSS_ORIGIN_OPENER_POLICY'] }));
|
|
112
113
|
await emitter.emitInit('app.before', { app });
|
|
113
114
|
await emitter.emitInit('middlewares.before', { app });
|
|
114
115
|
app.use(expressLogger);
|
|
@@ -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', {
|
|
@@ -254,6 +254,9 @@ CORS_CREDENTIALS=true
|
|
|
254
254
|
# Value for the Access-Control-Max-Age header [18000]
|
|
255
255
|
CORS_MAX_AGE=18000
|
|
256
256
|
|
|
257
|
+
# Value for the Cross-Origin-Opener-Policy header: same-origin, same-origin-allow-popups or unsafe-none ["same-origin"]
|
|
258
|
+
# CROSS_ORIGIN_OPENER_POLICY="same-origin"
|
|
259
|
+
|
|
257
260
|
####################################################################################################
|
|
258
261
|
### Argon2
|
|
259
262
|
|
package/dist/env.js
CHANGED
|
@@ -61,8 +61,10 @@ 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_.+',
|
|
67
|
+
'CROSS_ORIGIN_OPENER_POLICY',
|
|
66
68
|
// hashing
|
|
67
69
|
'HASH_.+',
|
|
68
70
|
'X_POWERED_BY_ENABLED',
|
|
@@ -283,6 +285,8 @@ const defaults = {
|
|
|
283
285
|
IP_TRUST_PROXY: true,
|
|
284
286
|
IP_CUSTOM_HEADER: false,
|
|
285
287
|
IMPORT_IP_DENY_LIST: ['0.0.0.0', '169.254.169.254'],
|
|
288
|
+
IMPORT_FILE_IP_ALLOW_LIST: [],
|
|
289
|
+
CROSS_ORIGIN_OPENER_POLICY: 'same-origin',
|
|
286
290
|
SERVE_APP: true,
|
|
287
291
|
SERVE_GRAPHQL: true,
|
|
288
292
|
RELATIONAL_BATCH_SIZE: 25000,
|
|
@@ -306,6 +310,7 @@ const typeMap = {
|
|
|
306
310
|
DB_EXCLUDE_TABLES: 'array',
|
|
307
311
|
CACHE_SKIP_ALLOWED: 'boolean',
|
|
308
312
|
IMPORT_IP_DENY_LIST: 'array',
|
|
313
|
+
IMPORT_FILE_IP_ALLOW_LIST: 'array',
|
|
309
314
|
FILE_METADATA_ALLOW_LIST: 'array',
|
|
310
315
|
SERVE_GRAPHQL: 'boolean',
|
|
311
316
|
GRAPHQL_INTROSPECTION: 'boolean',
|
|
@@ -4,7 +4,9 @@ export default defineOperationApi({
|
|
|
4
4
|
handler: ({ filter }, { data }) => {
|
|
5
5
|
const errors = validatePayload(filter, data, { requireAll: true });
|
|
6
6
|
if (errors.length > 0) {
|
|
7
|
-
|
|
7
|
+
// Joi errors hold the validated data ($env, $accountability…) in _original, details[].context and in some
|
|
8
|
+
// messages: only keep the path and type, both derived from the filter, as webhook flows can return them
|
|
9
|
+
throw errors.flatMap((error) => error.details.map(({ path, type }) => ({ path, type })));
|
|
8
10
|
}
|
|
9
11
|
else {
|
|
10
12
|
return null;
|
package/dist/request/index.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/request/index.js
CHANGED
|
@@ -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
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { validateIP } from './validate-ip.js';
|
|
2
2
|
export const responseInterceptor = async (config) => {
|
|
3
|
-
|
|
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) =>
|
|
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 =
|
|
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
|
};
|
package/dist/services/files.js
CHANGED
|
@@ -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) {
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { toArray } from '@wbce-d9/utils';
|
|
2
|
+
import { URL } from 'url';
|
|
2
3
|
import env from '../env.js';
|
|
3
4
|
import isUrlAllowed from './is-url-allowed.js';
|
|
4
|
-
|
|
5
|
+
// Sentinel origin that never resolves (RFC 6761): relative paths keep it,
|
|
6
|
+
// anything pointing elsewhere (`//x`, `/\x`, `https://x`) does not.
|
|
7
|
+
const RELATIVE_BASE = 'https://relative.invalid';
|
|
8
|
+
function isUrlValid(url, base) {
|
|
5
9
|
try {
|
|
6
|
-
new URL(
|
|
10
|
+
new URL(url, base);
|
|
7
11
|
return true;
|
|
8
12
|
}
|
|
9
13
|
catch (err) {
|
|
@@ -18,25 +22,20 @@ export function isRedirectAllowedOnLogin(redirect, provider) {
|
|
|
18
22
|
return true; // empty redirect
|
|
19
23
|
if (typeof redirect !== 'string')
|
|
20
24
|
return false; // invalid type
|
|
21
|
-
const publicUrl = env['PUBLIC_URL'];
|
|
22
25
|
if (!isUrlValid(redirect)) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
// domain without protocol `//example.com/test`
|
|
28
|
-
return false;
|
|
26
|
+
// relative path like `/admin/test`, allowed unless it resolves to another origin
|
|
27
|
+
return isUrlValid(redirect, RELATIVE_BASE) && new URL(redirect, RELATIVE_BASE).origin === RELATIVE_BASE;
|
|
29
28
|
}
|
|
30
|
-
const { protocol: redirectProtocol,
|
|
29
|
+
const { protocol: redirectProtocol, host: redirectHost } = new URL(redirect);
|
|
30
|
+
const publicUrl = env['PUBLIC_URL'];
|
|
31
31
|
const envKey = `AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`;
|
|
32
32
|
if (envKey in env) {
|
|
33
33
|
if (isUrlAllowed(redirect, [...toArray(env[envKey]), publicUrl]))
|
|
34
34
|
return true;
|
|
35
35
|
}
|
|
36
|
-
if (!isUrlValid(publicUrl))
|
|
36
|
+
if (!isUrlValid(publicUrl))
|
|
37
37
|
return false;
|
|
38
|
-
}
|
|
39
38
|
// allow redirects to the defined PUBLIC_URL
|
|
40
|
-
const { protocol: publicProtocol,
|
|
41
|
-
return
|
|
39
|
+
const { protocol: publicProtocol, host: publicHost } = new URL(publicUrl);
|
|
40
|
+
return redirectProtocol === publicProtocol && redirectHost === publicHost;
|
|
42
41
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wbce-d9/api",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.6.0",
|
|
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.0.1",
|
|
148
|
+
"@wbce-d9/exceptions": "10.0.1",
|
|
149
|
+
"@wbce-d9/extensions-sdk": "10.0.5",
|
|
150
|
+
"@wbce-d9/specs": "9.26.5",
|
|
146
151
|
"@wbce-d9/constants": "10.0.0",
|
|
147
|
-
"@wbce-d9/exceptions": "10.0.0",
|
|
148
|
-
"@wbce-d9/extensions-sdk": "10.0.4",
|
|
149
152
|
"@wbce-d9/schema": "10.0.0",
|
|
150
|
-
"@wbce-d9/
|
|
153
|
+
"@wbce-d9/storage-driver-azure": "10.0.4",
|
|
151
154
|
"@wbce-d9/storage": "10.0.0",
|
|
152
|
-
"@wbce-d9/storage-driver-
|
|
153
|
-
"@wbce-d9/storage-driver-
|
|
154
|
-
"@wbce-d9/
|
|
155
|
-
"@wbce-d9/storage-driver-gcs": "10.0.3",
|
|
156
|
-
"@wbce-d9/storage-driver-local": "10.0.3",
|
|
157
|
-
"@wbce-d9/storage-driver-s3": "10.0.3",
|
|
155
|
+
"@wbce-d9/storage-driver-cloudinary": "10.0.5",
|
|
156
|
+
"@wbce-d9/storage-driver-gcs": "10.0.4",
|
|
157
|
+
"@wbce-d9/storage-driver-s3": "10.0.4",
|
|
158
158
|
"@wbce-d9/update-check": "10.0.4",
|
|
159
|
-
"@wbce-d9/utils": "10.0.
|
|
159
|
+
"@wbce-d9/utils": "10.0.4",
|
|
160
|
+
"@wbce-d9/storage-driver-local": "10.0.4"
|
|
160
161
|
},
|
|
161
162
|
"devDependencies": {
|
|
162
163
|
"@ngneat/falso": "6.4.0",
|