@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 CHANGED
@@ -1,9 +1,9 @@
1
- # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.36.10-test.3/config/assets/logo.svg) Appsemble Node Utilities
1
+ # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.36.10-test.4/config/assets/logo.svg) Appsemble Node Utilities
2
2
 
3
3
  > NodeJS utilities used by Appsemble internally.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@appsemble/node-utils)](https://www.npmjs.com/package/@appsemble/node-utils)
6
- [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.36.10-test.3/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.36.10-test.3)
6
+ [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.36.10-test.4/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.36.10-test.4)
7
7
  [![Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](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.3/LICENSE.md) ©
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.setIn(key, value);
138
+ expandPatchParentCollections(doc, key);
139
+ doc.setIn(key, createPatchValue(doc, value));
113
140
  }
114
141
  }
115
142
  const prettierOptions = (await resolveConfig(path, { editorconfig: true }));
@@ -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.
@@ -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;
@@ -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: pod.metadata.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: pod.metadata.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
- // Cache app icons for 1 week.
65
- if (cache) {
66
- ctx.set('cache-control', `public, max-age=${60 * 60 * 24 * 7},immutable`);
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
@@ -47,3 +47,4 @@ export * from './PhoneNumberValidationError.js';
47
47
  export * from './createUser.js';
48
48
  export * from './deleteUser.js';
49
49
  export * from './updateSubscription.js';
50
+ export * from './server/utils/ssrf.js';
package/index.js CHANGED
@@ -47,4 +47,5 @@ export * from './PhoneNumberValidationError.js';
47
47
  export * from './createUser.js';
48
48
  export * from './deleteUser.js';
49
49
  export * from './updateSubscription.js';
50
+ export * from './server/utils/ssrf.js';
50
51
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/node-utils",
3
- "version": "0.36.10-test.3",
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.3",
44
- "@appsemble/types": "0.36.10-test.3",
45
- "@appsemble/utils": "0.36.10-test.3",
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.2",
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();
@@ -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
- // VITEST_CONF_ALLOW_PRIVATE_IP_PROXY: Test-only env var set in vitest.setup.ts
152
- // to allow proxy tests to use localhost servers. SSRF tests explicitly unset this.
153
- // This env var should NEVER be set in production.
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