@appsemble/node-utils 0.36.10-test.3 → 0.36.10-test.4
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/README.md +3 -3
- package/app.js +29 -2
- package/container/helpers.d.ts +4 -4
- package/container/helpers.js +48 -7
- package/container/operations.js +3 -2
- package/icon.js +4 -4
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +6 -5
- package/s3.d.ts +1 -1
- package/s3.js +7 -9
- package/server/utils/actions.js +7 -18
- package/server/utils/ssrf.d.ts +11 -0
- package/server/utils/ssrf.js +88 -0
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
#  Appsemble Node Utilities
|
|
2
2
|
|
|
3
3
|
> NodeJS utilities used by Appsemble internally.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@appsemble/node-utils)
|
|
6
|
-
[](https://gitlab.com/appsemble/appsemble/-/releases/0.36.10-test.4)
|
|
7
7
|
[](https://prettier.io)
|
|
8
8
|
|
|
9
9
|
## Table of Contents
|
|
@@ -26,5 +26,5 @@ compatibility is not guaranteed.
|
|
|
26
26
|
|
|
27
27
|
## License
|
|
28
28
|
|
|
29
|
-
[LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.36.10-test.
|
|
29
|
+
[LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.36.10-test.4/LICENSE.md) ©
|
|
30
30
|
[Appsemble](https://appsemble.com)
|
package/app.js
CHANGED
|
@@ -9,11 +9,37 @@ import { copy, ensureDir } from 'fs-extra';
|
|
|
9
9
|
import { IntlMessageFormat } from 'intl-messageformat';
|
|
10
10
|
import lodash from 'lodash';
|
|
11
11
|
import { format, resolveConfig } from 'prettier';
|
|
12
|
-
import { parseDocument } from 'yaml';
|
|
12
|
+
import { Alias, isCollection, parseDocument } from 'yaml';
|
|
13
13
|
import { opendirSafe, readData, writeData } from './fs.js';
|
|
14
14
|
import { logger } from './logger.js';
|
|
15
15
|
const getNumberFormat = memoize((locale, opts) => new Intl.NumberFormat(locale, opts));
|
|
16
16
|
const getPluralRules = memoize((locale, opts) => new Intl.PluralRules(locale, opts));
|
|
17
|
+
const aliasReferencePattern = /^\*(?!$)[^\s,[\]{}]+$/u;
|
|
18
|
+
function expandPatchParentCollections(doc, path) {
|
|
19
|
+
for (let index = 0; index < path.length; index += 1) {
|
|
20
|
+
const node = doc.getIn(path.slice(0, index), true);
|
|
21
|
+
if (isCollection(node)) {
|
|
22
|
+
node.flow = false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function createPatchValue(doc, value) {
|
|
27
|
+
if (typeof value === 'string' && aliasReferencePattern.test(value)) {
|
|
28
|
+
return new Alias(value.slice(1));
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
return doc.createNode(value.map((item) => createPatchValue(doc, item)), {
|
|
32
|
+
aliasDuplicateObjects: false,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
36
|
+
return doc.createNode(Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [
|
|
37
|
+
entryKey,
|
|
38
|
+
createPatchValue(doc, entryValue),
|
|
39
|
+
])), { aliasDuplicateObjects: false });
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
17
43
|
/**
|
|
18
44
|
* Get a context for remappers based on an app definition.
|
|
19
45
|
*
|
|
@@ -109,7 +135,8 @@ export async function patchDefinition(appPath, patches) {
|
|
|
109
135
|
doc.deleteIn(key);
|
|
110
136
|
}
|
|
111
137
|
else {
|
|
112
|
-
doc
|
|
138
|
+
expandPatchParentCollections(doc, key);
|
|
139
|
+
doc.setIn(key, createPatchValue(doc, value));
|
|
113
140
|
}
|
|
114
141
|
}
|
|
115
142
|
const prettierOptions = (await resolveConfig(path, { editorconfig: true }));
|
package/container/helpers.d.ts
CHANGED
|
@@ -4,15 +4,15 @@ export declare const maxCPU: number;
|
|
|
4
4
|
export declare const maxMemoryGi: number;
|
|
5
5
|
export declare const appIdLabel = "appId";
|
|
6
6
|
export declare const resourceDefaults: ContainerResourceProps;
|
|
7
|
+
export declare function getContainerNamespace(): string;
|
|
8
|
+
export declare function formatServiceName(containerName: string, appName: string, appId: string): string;
|
|
9
|
+
export declare function formatSecretName(appName: string, appId: string): string;
|
|
10
|
+
export declare function handleKubernetesError(error: unknown): void;
|
|
7
11
|
export declare function getKubeConfig(): {
|
|
8
12
|
appsApi: AppsV1Api;
|
|
9
13
|
coreApi: CoreV1Api;
|
|
10
14
|
kubeconfig: KubeConfig;
|
|
11
15
|
};
|
|
12
|
-
export declare function getContainerNamespace(): string;
|
|
13
|
-
export declare function formatServiceName(containerName: string, appName: string, appId: string): string;
|
|
14
|
-
export declare function formatSecretName(appName: string, appId: string): string;
|
|
15
|
-
export declare function handleKubernetesError(error: unknown): void;
|
|
16
16
|
export declare function deleteResource(type: 'deployment' | 'secret' | 'service', namespace: string, name: string): Promise<void>;
|
|
17
17
|
/**
|
|
18
18
|
* Accepts a url used to call a companion container.
|
package/container/helpers.js
CHANGED
|
@@ -5,13 +5,6 @@ export const maxCPU = process.env.MAX_CONTAINER_CPU ?? 3;
|
|
|
5
5
|
export const maxMemoryGi = process.env.MAX_CONTAINER_MEMORY ?? 3;
|
|
6
6
|
export const appIdLabel = 'appId';
|
|
7
7
|
export const resourceDefaults = { memory: '128Mi', cpu: '0.1' };
|
|
8
|
-
export function getKubeConfig() {
|
|
9
|
-
const kubeconfig = new KubeConfig();
|
|
10
|
-
kubeconfig.loadFromDefault();
|
|
11
|
-
const appsApi = kubeconfig.makeApiClient(AppsV1Api);
|
|
12
|
-
const coreApi = kubeconfig.makeApiClient(CoreV1Api);
|
|
13
|
-
return { appsApi, coreApi, kubeconfig };
|
|
14
|
-
}
|
|
15
8
|
export function getContainerNamespace() {
|
|
16
9
|
return `companion-containers-${process.env.SERVICE_NAME ?? 'appsemble'}`;
|
|
17
10
|
}
|
|
@@ -32,6 +25,54 @@ export function handleKubernetesError(error) {
|
|
|
32
25
|
}
|
|
33
26
|
logger.error(error);
|
|
34
27
|
}
|
|
28
|
+
function sleep(timeout) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
setTimeout(resolve, timeout);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function isTransientKubernetesError(error) {
|
|
34
|
+
if (!(error instanceof ApiException)) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return (typeof error.code === 'number' &&
|
|
38
|
+
(error.code === 408 || error.code === 429 || error.code >= 500));
|
|
39
|
+
}
|
|
40
|
+
async function withKubernetesRetry(description, operation) {
|
|
41
|
+
const attempts = Number(process.env.KUBERNETES_REQUEST_RETRIES ?? 3);
|
|
42
|
+
const delay = Number(process.env.KUBERNETES_RETRY_DELAY_MS ?? 1000);
|
|
43
|
+
for (let attempt = 1;; attempt += 1) {
|
|
44
|
+
try {
|
|
45
|
+
return await operation();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (attempt >= attempts || !isTransientKubernetesError(error)) {
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
logger.warn(`Kubernetes request failed while trying to ${description}. Retrying ${attempt}/${attempts}`);
|
|
52
|
+
if (delay > 0) {
|
|
53
|
+
await sleep(delay * attempt);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function withKubernetesRetries(description, client) {
|
|
59
|
+
return new Proxy(client, {
|
|
60
|
+
get(target, property) {
|
|
61
|
+
const value = Reflect.get(target, property, target);
|
|
62
|
+
if (typeof value !== 'function') {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
return (...args) => withKubernetesRetry(`${description}.${String(property)}`, () => value.apply(target, args));
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export function getKubeConfig() {
|
|
70
|
+
const kubeconfig = new KubeConfig();
|
|
71
|
+
kubeconfig.loadFromDefault();
|
|
72
|
+
const appsApi = withKubernetesRetries('AppsV1Api', kubeconfig.makeApiClient(AppsV1Api));
|
|
73
|
+
const coreApi = withKubernetesRetries('CoreV1Api', kubeconfig.makeApiClient(CoreV1Api));
|
|
74
|
+
return { appsApi, coreApi, kubeconfig };
|
|
75
|
+
}
|
|
35
76
|
export async function deleteResource(type, namespace, name) {
|
|
36
77
|
if (process.env.TEST) {
|
|
37
78
|
return;
|
package/container/operations.js
CHANGED
|
@@ -212,6 +212,7 @@ export async function updateCompanionContainers(definitions, appName, appId, reg
|
|
|
212
212
|
logger.silly(`Deployment ${serviceName} has no matching pod with metadata.name, skipping`);
|
|
213
213
|
continue;
|
|
214
214
|
}
|
|
215
|
+
const podName = pod.metadata.name;
|
|
215
216
|
// Set base labels, such as selector, pod name, pod hash
|
|
216
217
|
const props = {
|
|
217
218
|
...def.metadata,
|
|
@@ -221,7 +222,7 @@ export async function updateCompanionContainers(definitions, appName, appId, reg
|
|
|
221
222
|
'pod-template-hash': pod?.metadata?.labels?.['pod-template-hash'],
|
|
222
223
|
appId,
|
|
223
224
|
},
|
|
224
|
-
name:
|
|
225
|
+
name: podName,
|
|
225
226
|
};
|
|
226
227
|
const podPatch = [
|
|
227
228
|
{
|
|
@@ -231,7 +232,7 @@ export async function updateCompanionContainers(definitions, appName, appId, reg
|
|
|
231
232
|
},
|
|
232
233
|
];
|
|
233
234
|
const updatedPod = await coreApi.patchNamespacedPod({
|
|
234
|
-
name:
|
|
235
|
+
name: podName,
|
|
235
236
|
namespace,
|
|
236
237
|
body: podPatch,
|
|
237
238
|
});
|
package/icon.js
CHANGED
|
@@ -61,10 +61,10 @@ export async function serveIcon(ctx, { background, cache, fallback, height, icon
|
|
|
61
61
|
if (background) {
|
|
62
62
|
img.flatten({ background });
|
|
63
63
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
64
|
+
}
|
|
65
|
+
// Cache app icons for 1 week.
|
|
66
|
+
if (cache) {
|
|
67
|
+
ctx.set('cache-control', `public, max-age=${60 * 60 * 24 * 7},immutable`);
|
|
68
68
|
}
|
|
69
69
|
ctx.body = await img.toFormat('png').toBuffer();
|
|
70
70
|
ctx.type = 'image/png';
|
package/index.d.ts
CHANGED
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appsemble/node-utils",
|
|
3
|
-
"version": "0.36.10-test.
|
|
3
|
+
"version": "0.36.10-test.4",
|
|
4
4
|
"description": "NodeJS utilities used by Appsemble internally.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"app",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"test": "vitest"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@appsemble/lang-sdk": "0.36.10-test.
|
|
44
|
-
"@appsemble/types": "0.36.10-test.
|
|
45
|
-
"@appsemble/utils": "0.36.10-test.
|
|
43
|
+
"@appsemble/lang-sdk": "0.36.10-test.4",
|
|
44
|
+
"@appsemble/types": "0.36.10-test.4",
|
|
45
|
+
"@appsemble/utils": "0.36.10-test.4",
|
|
46
46
|
"@formatjs/fast-memoize": "^2.0.0",
|
|
47
47
|
"@fortawesome/fontawesome-free": "^6.0.0",
|
|
48
48
|
"@inquirer/prompts": "^8.0.0",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"form-data": "^4.0.4",
|
|
68
68
|
"fs-extra": "^11.0.0",
|
|
69
69
|
"intl-messageformat": "^11.0.0",
|
|
70
|
+
"ipaddr.js": "^2.4.0",
|
|
70
71
|
"jsonschema": "~1.4.1",
|
|
71
72
|
"keytar": "^7.0.0",
|
|
72
73
|
"koa": "^3.0.0",
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
"lodash": "^4.0.0",
|
|
83
84
|
"lodash-es": "^4.0.0",
|
|
84
85
|
"logform": "^2.0.0",
|
|
85
|
-
"memfs": "4.57.
|
|
86
|
+
"memfs": "4.57.8",
|
|
86
87
|
"mime-types": "^2.0.0",
|
|
87
88
|
"minio": "^8.0.3",
|
|
88
89
|
"mustache": "^4.0.0",
|
package/s3.d.ts
CHANGED
|
@@ -13,6 +13,6 @@ export declare function uploadS3FileFromPath(bucket: string, key: string, path:
|
|
|
13
13
|
export declare function getS3File(bucket: string, key: string): Promise<Readable>;
|
|
14
14
|
export declare function getS3FileBuffer(bucket: string, key: string): Promise<Buffer>;
|
|
15
15
|
export declare function getS3FileStats(bucket: string, key: string): Promise<BucketItemStat>;
|
|
16
|
-
export declare function deleteS3File(bucket: string, key: string): Promise<void>;
|
|
17
16
|
export declare function deleteS3Files(bucket: string, keys: string[]): Promise<void>;
|
|
17
|
+
export declare function deleteS3File(bucket: string, key: string): Promise<void>;
|
|
18
18
|
export declare function clearAllS3Buckets(): Promise<void>;
|
package/s3.js
CHANGED
|
@@ -92,24 +92,22 @@ export async function getS3FileStats(bucket, key) {
|
|
|
92
92
|
throw error;
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
-
export async function deleteS3File(bucket, key) {
|
|
96
|
-
try {
|
|
97
|
-
await s3Client.removeObject(bucket, key);
|
|
98
|
-
}
|
|
99
|
-
catch (error) {
|
|
100
|
-
logger.error(error);
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
95
|
export async function deleteS3Files(bucket, keys) {
|
|
105
96
|
try {
|
|
106
97
|
await s3Client.removeObjects(bucket, keys);
|
|
107
98
|
}
|
|
108
99
|
catch (error) {
|
|
100
|
+
if (error instanceof S3Error && error.code === 'NoSuchBucket') {
|
|
101
|
+
logger.warn(`S3 bucket "${bucket}" does not exist; skipping deletion`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
109
104
|
logger.error(error);
|
|
110
105
|
throw error;
|
|
111
106
|
}
|
|
112
107
|
}
|
|
108
|
+
export async function deleteS3File(bucket, key) {
|
|
109
|
+
await deleteS3Files(bucket, [key]);
|
|
110
|
+
}
|
|
113
111
|
export async function clearAllS3Buckets() {
|
|
114
112
|
try {
|
|
115
113
|
const buckets = await s3Client.listBuckets();
|
package/server/utils/actions.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { defaultLocale, remap, } from '@appsemble/lang-sdk';
|
|
2
|
-
import { assertKoaCondition, createFormData, EmailQuotaExceededError, getContainerNamespace, getRemapperContext, logger, parseServiceUrl, scaleDeployment, setLastRequestAnnotation, throwKoaError, version, waitForPodReadiness, } from '@appsemble/node-utils';
|
|
2
|
+
import { assertKoaCondition, createFormData, EmailQuotaExceededError, getContainerNamespace, getRemapperContext, getSSRFProtectedAgents, logger, parseServiceUrl, scaleDeployment, setLastRequestAnnotation, throwKoaError, version, waitForPodReadiness, } from '@appsemble/node-utils';
|
|
3
3
|
import axios from 'axios';
|
|
4
4
|
import { get, mapValues, pick } from 'lodash-es';
|
|
5
|
-
import { RequestFilteringHttpAgent, RequestFilteringHttpsAgent } from 'request-filtering-agent';
|
|
6
5
|
/**
|
|
7
6
|
* These response headers are forwarded when proxying requests.
|
|
8
7
|
*/
|
|
@@ -147,23 +146,13 @@ async function handleRequestProxy(ctx, app, action, useBody, options) {
|
|
|
147
146
|
// Apply SSRF protection for non-companion-container URLs
|
|
148
147
|
// This blocks requests to private IPs, localhost, link-local addresses,
|
|
149
148
|
// and hostnames that resolve to private IPs (prevents DNS rebinding attacks)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const allowPrivateIPAddress = process.env.VITEST_CONF_ALLOW_PRIVATE_IP_PROXY === '1';
|
|
155
|
-
// Preserve any existing agent options (e.g., client certs from applyAppServiceSecrets)
|
|
156
|
-
// while still applying SSRF protection
|
|
157
|
-
const existingHttpsOptions = axiosConfig.httpsAgent?.options ?? {};
|
|
158
|
-
const existingHttpOptions = axiosConfig.httpAgent?.options ?? {};
|
|
159
|
-
axiosConfig.httpAgent = new RequestFilteringHttpAgent({
|
|
160
|
-
...existingHttpOptions,
|
|
161
|
-
allowPrivateIPAddress,
|
|
162
|
-
});
|
|
163
|
-
axiosConfig.httpsAgent = new RequestFilteringHttpsAgent({
|
|
164
|
-
...existingHttpsOptions,
|
|
165
|
-
allowPrivateIPAddress,
|
|
149
|
+
const { httpAgent, httpsAgent } = await getSSRFProtectedAgents({
|
|
150
|
+
hostname: proxyUrl.hostname,
|
|
151
|
+
httpAgent: axiosConfig.httpAgent,
|
|
152
|
+
httpsAgent: axiosConfig.httpsAgent,
|
|
166
153
|
});
|
|
154
|
+
axiosConfig.httpAgent = httpAgent;
|
|
155
|
+
axiosConfig.httpsAgent = httpsAgent;
|
|
167
156
|
}
|
|
168
157
|
logger.verbose(`Forwarding request to ${axios.getUri(axiosConfig)}`);
|
|
169
158
|
logger.verbose('Axios Config:');
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { RequestFilteringHttpAgent, RequestFilteringHttpsAgent } from 'request-filtering-agent';
|
|
2
|
+
export interface SSRFOptions {
|
|
3
|
+
allowPrivateIPAddress?: boolean;
|
|
4
|
+
hostname?: string;
|
|
5
|
+
httpAgent?: any;
|
|
6
|
+
httpsAgent?: any;
|
|
7
|
+
}
|
|
8
|
+
export declare function getSSRFProtectedAgents(options?: SSRFOptions): Promise<{
|
|
9
|
+
httpAgent: RequestFilteringHttpAgent;
|
|
10
|
+
httpsAgent: RequestFilteringHttpsAgent;
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import dns from 'node:dns';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import ipaddr from 'ipaddr.js';
|
|
4
|
+
import { RequestFilteringHttpAgent, RequestFilteringHttpsAgent } from 'request-filtering-agent';
|
|
5
|
+
/**
|
|
6
|
+
* Detect NAT64 (RFC 6052 well-known prefix) and IPv4-mapped IPv6 addresses whose embedded IPv4 is a
|
|
7
|
+
* public unicast address.
|
|
8
|
+
*
|
|
9
|
+
* `request-filtering-agent` blocks these IPv6 forms, so when they wrap a legitimate public address
|
|
10
|
+
* we return it to be added to the allow-list. Addresses embedding a private/loopback/link-local
|
|
11
|
+
* IPv4 (e.g. `64:ff9b::127.0.0.1`) return `undefined` and stay blocked.
|
|
12
|
+
*
|
|
13
|
+
* @param address An IP address string (not a hostname).
|
|
14
|
+
* @returns The address when it should be allow-listed, otherwise `undefined`.
|
|
15
|
+
*/
|
|
16
|
+
function getAllowableEmbeddedAddress(address) {
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = ipaddr.parse(address);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
if (parsed.kind() !== 'ipv6') {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const ipv6 = parsed;
|
|
28
|
+
const range = ipv6.range();
|
|
29
|
+
let ipv4;
|
|
30
|
+
if (range === 'rfc6052') {
|
|
31
|
+
const parts = ipv6.toByteArray();
|
|
32
|
+
// For the well-known prefix /96, the IPv4 is in parts[12...15]
|
|
33
|
+
if (parts[0] === 0x00 && parts[1] === 0x64 && parts[2] === 0xff && parts[3] === 0x9b) {
|
|
34
|
+
ipv4 = new ipaddr.IPv4(parts.slice(12));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else if (range === 'ipv4Mapped') {
|
|
38
|
+
ipv4 = ipv6.toIPv4Address();
|
|
39
|
+
}
|
|
40
|
+
return ipv4 && ipv4.range() === 'unicast' ? address : undefined;
|
|
41
|
+
}
|
|
42
|
+
export async function getSSRFProtectedAgents(options = {}) {
|
|
43
|
+
const { allowPrivateIPAddress = process.env.VITEST_CONF_ALLOW_PRIVATE_IP_PROXY === '1', hostname, } = options;
|
|
44
|
+
const allowIPAddressList = process.env.SSRF_ALLOW_IP_ADDRESS_LIST?.split(',') ?? [];
|
|
45
|
+
// `URL.hostname` wraps IPv6 literals in brackets (e.g. `[64:ff9b::1]`); strip them so the value
|
|
46
|
+
// can be parsed as an IP and resolved by DNS.
|
|
47
|
+
const normalizedHostname = hostname?.replace(/^\[|]$/g, '');
|
|
48
|
+
if (normalizedHostname) {
|
|
49
|
+
if (isIP(normalizedHostname)) {
|
|
50
|
+
// IP literal: inspect it directly. No DNS lookup is needed (which also avoids resolving on top
|
|
51
|
+
// of the lookup request-filtering-agent already performs at connect time).
|
|
52
|
+
const allowable = getAllowableEmbeddedAddress(normalizedHostname);
|
|
53
|
+
if (allowable) {
|
|
54
|
+
allowIPAddressList.push(allowable);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
// Real hostname: resolve to detect NAT64/DNS64-synthesised addresses.
|
|
59
|
+
try {
|
|
60
|
+
const addresses = await dns.promises.lookup(normalizedHostname, { all: true });
|
|
61
|
+
for (const { address } of addresses) {
|
|
62
|
+
const allowable = getAllowableEmbeddedAddress(address);
|
|
63
|
+
if (allowable) {
|
|
64
|
+
allowIPAddressList.push(allowable);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Ignore DNS lookup errors, the agent will handle them
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const existingHttpOptions = options.httpAgent?.options ?? {};
|
|
74
|
+
const existingHttpsOptions = options.httpsAgent?.options ?? {};
|
|
75
|
+
return {
|
|
76
|
+
httpAgent: new RequestFilteringHttpAgent({
|
|
77
|
+
...existingHttpOptions,
|
|
78
|
+
allowPrivateIPAddress,
|
|
79
|
+
allowIPAddressList,
|
|
80
|
+
}),
|
|
81
|
+
httpsAgent: new RequestFilteringHttpsAgent({
|
|
82
|
+
...existingHttpsOptions,
|
|
83
|
+
allowPrivateIPAddress,
|
|
84
|
+
allowIPAddressList,
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=ssrf.js.map
|