@appsemble/node-utils 0.36.2-test.5 → 0.36.3-test.5

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.2-test.5/config/assets/logo.svg) Appsemble Node Utilities
1
+ # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.36.3-test.5/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.2-test.5/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.36.2-test.5)
6
+ [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.36.3-test.5/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.36.3-test.5)
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.2-test.5/LICENSE.md) ©
29
+ [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.36.3-test.5/LICENSE.md) ©
30
30
  [Appsemble](https://appsemble.com)
package/authentication.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { AppsembleError, logger } from '@appsemble/node-utils';
2
+ import { select } from '@inquirer/prompts';
2
3
  import axios from 'axios';
3
- import inquirer from 'inquirer';
4
4
  export const CREDENTIALS_ENV_VAR = 'APPSEMBLE_CLIENT_CREDENTIALS';
5
5
  const authorizedRemotes = new Set();
6
6
  export function getService(remote) {
@@ -34,14 +34,10 @@ async function getClientCredentials(remote, inputCredentials) {
34
34
  [choice] = choices;
35
35
  }
36
36
  else {
37
- ({ choice } = await inquirer.prompt([
38
- {
39
- name: 'choice',
40
- message: 'Select client id to use',
41
- choices: choices.map((value) => ({ name: value.account, value })),
42
- type: 'list',
43
- },
44
- ]));
37
+ choice = await select({
38
+ message: 'Select client id to use',
39
+ choices: choices.map((value) => ({ name: value.account, value })),
40
+ });
45
41
  }
46
42
  return `${choice.account}:${choice.password}`;
47
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/node-utils",
3
- "version": "0.36.2-test.5",
3
+ "version": "0.36.3-test.5",
4
4
  "description": "NodeJS utilities used by Appsemble internally.",
5
5
  "keywords": [
6
6
  "app",
@@ -40,10 +40,9 @@
40
40
  "test": "vitest"
41
41
  },
42
42
  "dependencies": {
43
- "@appsemble/lang-sdk": "0.36.2-test.5",
44
- "@appsemble/types": "0.36.2-test.5",
45
- "@appsemble/utils": "0.36.2-test.5",
46
- "@appsemble/webpack-core": "0.36.2-test.5",
43
+ "@appsemble/lang-sdk": "0.36.3-test.5",
44
+ "@appsemble/types": "0.36.3-test.5",
45
+ "@appsemble/utils": "0.36.3-test.5",
47
46
  "@formatjs/fast-memoize": "^2.0.0",
48
47
  "@fortawesome/fontawesome-free": "^6.0.0",
49
48
  "@kubernetes/client-node": "^0.22.2",
@@ -65,11 +64,11 @@
65
64
  "fast-glob": "^3.0.0",
66
65
  "form-data": "^4.0.4",
67
66
  "fs-extra": "^11.0.0",
68
- "inquirer": "^9.0.0",
67
+ "@inquirer/prompts": "^8.0.0",
69
68
  "intl-messageformat": "^11.0.0",
70
69
  "jsonschema": "~1.4.1",
71
70
  "keytar": "^7.0.0",
72
- "koa": "^2.0.0",
71
+ "koa": "^3.0.0",
73
72
  "koa-compose": "^4.0.0",
74
73
  "koa-mount": "^4.0.0",
75
74
  "koa-static": "^5.0.0",
@@ -82,7 +81,7 @@
82
81
  "lodash": "^4.0.0",
83
82
  "lodash-es": "^4.0.0",
84
83
  "logform": "^2.0.0",
85
- "memfs": "^4.17.0",
84
+ "memfs": "4.17.0",
86
85
  "mime-types": "^2.0.0",
87
86
  "minio": "^8.0.3",
88
87
  "mustache": "^4.0.0",
@@ -90,6 +89,7 @@
90
89
  "parse-duration": "^1.0.0",
91
90
  "parse-json": "^8.0.0",
92
91
  "pg": "^8.16.3",
92
+ "request-filtering-agent": "^3.2.0",
93
93
  "sass": "^1.0.0",
94
94
  "sharp": "^0.32.0",
95
95
  "sort-keys": "^5.0.0",
@@ -108,7 +108,7 @@
108
108
  "@types/css-tree": "2.3.11",
109
109
  "@types/express-to-koa": "1.0.4",
110
110
  "@types/koa-mount": "4.0.5",
111
- "@types/mustache": "4.2.5",
111
+ "@types/mustache": "4.2.6",
112
112
  "@types/type-is": "1.6.7",
113
113
  "@types/uuid": "10.0.0",
114
114
  "axios-mock-adapter": "1.22.0",
@@ -124,6 +124,6 @@
124
124
  }
125
125
  },
126
126
  "engines": {
127
- "node": ">=20"
127
+ "node": ">=24"
128
128
  }
129
129
  }
package/readAsset.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const assetDir: import("node:url").URL;
1
+ export declare const assetDir: import("url").URL;
2
2
  /**
3
3
  * Read a file from the server assets directory.
4
4
  *
@@ -34,9 +34,9 @@ export function createIndexHandler({ createSettings, getApp, getAppDetails, getA
34
34
  }
35
35
  const appUrl = await getAppUrl({ app, context: ctx });
36
36
  if (appUrl.hostname !== hostname) {
37
- appUrl.pathname = path;
38
- appUrl.search = ctx.querystring;
39
- ctx.redirect(String(appUrl));
37
+ const redirectUrl = new URL(path, appUrl);
38
+ redirectUrl.search = ctx.search;
39
+ ctx.redirect(String(redirectUrl));
40
40
  return;
41
41
  }
42
42
  const defaultLanguage = app.definition.defaultLanguage || defaultLocale;
@@ -1,3 +1,3 @@
1
1
  import { type Middleware } from 'koa';
2
2
  import { type Options } from '../../../index.js';
3
- export declare function createSecurityHandler({ getApp }: Options): Middleware;
3
+ export declare function createSecurityHandler({ getApp, getSecurityEmail }: Options): Middleware;
@@ -1,11 +1,20 @@
1
1
  import { addDays } from 'date-fns';
2
2
  import { assertKoaCondition } from '../../../index.js';
3
- export function createSecurityHandler({ getApp }) {
3
+ export function createSecurityHandler({ getApp, getSecurityEmail }) {
4
4
  return async (ctx) => {
5
5
  const app = await getApp({ context: ctx, query: { attributes: ['id', 'updated'] } });
6
6
  assertKoaCondition(app != null, ctx, 404, 'App not found');
7
7
  const expires = addDays(new Date(app.$updated ?? app.$created), 180);
8
- ctx.body = `Contact: mailto:security@appsemble.com\nExpires: ${expires}`;
8
+ const securityEmail = getSecurityEmail();
9
+ ctx.type = 'text/plain; charset=utf-8';
10
+ ctx.body = [
11
+ `Contact: mailto:${securityEmail}`,
12
+ `Expires: ${expires.toISOString().replace(/\.\d{3}Z$/, 'Z')}`,
13
+ `Canonical: ${ctx.URL.origin}/.well-known/security.txt`,
14
+ 'Policy: https://gitlab.com/appsemble/appsemble/-/blob/main/SECURITY.md',
15
+ 'Preferred-Languages: en, nl',
16
+ '',
17
+ ].join('\n');
9
18
  };
10
19
  }
11
20
  //# sourceMappingURL=securityHandler.js.map
package/server/types.d.ts CHANGED
@@ -373,6 +373,7 @@ export interface ParsedQuery {
373
373
  }
374
374
  export type ContentSecurityPolicy = Record<string, (string | false)[]>;
375
375
  export interface Options {
376
+ getSecurityEmail: () => string;
376
377
  getCurrentAppMember: (params: GetCurrentAppMemberParams) => Promise<AppMemberInfo | null>;
377
378
  getCurrentAppMemberSelectedGroup: (params: GetCurrentAppMemberSelectedGroupParams) => Promise<AppMemberGroup | null>;
378
379
  getCurrentAppMemberGroups: (params: GetCurrentAppMemberGroupsParams) => Promise<Group[] | null>;
@@ -2,6 +2,7 @@ import { defaultLocale, remap, } from '@appsemble/lang-sdk';
2
2
  import { assertKoaCondition, createFormData, EmailQuotaExceededError, getContainerNamespace, getRemapperContext, 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';
5
6
  /**
6
7
  * These response headers are forwarded when proxying requests.
7
8
  */
@@ -121,6 +122,18 @@ async function handleRequestProxy(ctx, app, action, useBody, options) {
121
122
  axiosConfig.decompress = false;
122
123
  let response;
123
124
  const containerUrlPattern = /^http:\/\/(([\da-z-]+).){2}svc.cluster.local/;
125
+ let isValidCompanionContainer = false;
126
+ // Block known Kubernetes internal hostnames that don't match the container pattern
127
+ const kubernetesInternalHostnames = [
128
+ 'kubernetes',
129
+ 'kubernetes.default',
130
+ 'kubernetes.default.svc',
131
+ 'kubernetes.default.svc.cluster.local',
132
+ ];
133
+ if (kubernetesInternalHostnames.includes(proxyUrl.hostname.toLowerCase())) {
134
+ logger.warn(`SSRF blocked: Kubernetes internal hostname ${proxyUrl.hostname}`);
135
+ throwKoaError(ctx, 403, 'Access to Kubernetes internal services is not allowed');
136
+ }
124
137
  // Restricting access to only the containers defined by the app
125
138
  if (containerUrlPattern.test(String(proxyUrl))) {
126
139
  axiosConfig.url = axiosConfig.url?.replace(axiosConfig.url.split('.')[1], getContainerNamespace());
@@ -128,6 +141,29 @@ async function handleRequestProxy(ctx, app, action, useBody, options) {
128
141
  if (appId !== String(app.id)) {
129
142
  throwKoaError(ctx, 403, 'Forbidden');
130
143
  }
144
+ isValidCompanionContainer = true;
145
+ }
146
+ else {
147
+ // Apply SSRF protection for non-companion-container URLs
148
+ // This blocks requests to private IPs, localhost, link-local addresses,
149
+ // 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,
166
+ });
131
167
  }
132
168
  logger.verbose(`Forwarding request to ${axios.getUri(axiosConfig)}`);
133
169
  logger.verbose('Axios Config:');
@@ -137,9 +173,15 @@ async function handleRequestProxy(ctx, app, action, useBody, options) {
137
173
  logger.verbose(response);
138
174
  }
139
175
  catch (err) {
176
+ // Check if this is an SSRF block from request-filtering-agent
177
+ const errorMessage = err instanceof Error ? err.message : String(err);
178
+ if (errorMessage.includes('is not allowed') || errorMessage.includes('private')) {
179
+ logger.warn(`SSRF blocked: ${errorMessage}`);
180
+ throwKoaError(ctx, 403, 'Access to private network addresses is not allowed');
181
+ }
140
182
  // If request is sent to a companion container and fails
141
183
  // Try to start it anew and retry the request
142
- if (containerUrlPattern.test(String(proxyUrl))) {
184
+ if (isValidCompanionContainer) {
143
185
  const { deploymentName, namespace } = parseServiceUrl(String(proxyUrl));
144
186
  try {
145
187
  await scaleDeployment(namespace, deploymentName, 1);