Medium severity6.5NVD Advisory· Published Apr 18, 2026· Updated May 13, 2026
CVE-2026-40346
CVE-2026-40346
Description
NocoBase is an AI-powered no-code/low-code platform for building business applications and enterprise solutions. Prior to version 2.0.37, NocoBase's workflow HTTP request plugin and custom request action plugin make server-side HTTP requests to user-provided URLs without any SSRF protection. An authenticated user can access internal network services, cloud metadata endpoints, and localhost. Version 2.0.37 contains a patch.
Affected products
1Patches
12853368243edfix(plugin-workflow-request): add server-request to package request security logic (#9079)
19 files changed · +716 −14
docs/docs/cn/get-started/installation/env.md+21 −0 modified@@ -476,3 +476,24 @@ yarn cross-env \ ### WORKFLOW_LOOP_LIMIT 工作流循环节点的最大循环次数限制,详情查看「[循环节点](/workflow/nodes/loop#WORKFLOW_LOOP_LIMIT)」。 + +### SERVER_REQUEST_WHITELIST + +服务端对外发送 HTTP 请求的目标白名单,用于防止 SSRF(服务端请求伪造)攻击。逗号分隔,支持精确 IP、CIDR 范围、精确域名和通配符子域名(单级)。 + +```bash +SERVER_REQUEST_WHITELIST=1.2.3.4,10.0.0.0/8,api.example.com,*.trusted.com +``` + +**适用范围**:工作流「HTTP 请求」节点、自定义操作按钮的「自定义请求」。相对路径(调用 NocoBase 自身 API)不受此限制影响。 + +**未配置时**:所有 `http`/`https` 请求均放行(保持原有行为)。**配置后**:仅允许匹配白名单的请求,不匹配的请求会报错。 + +支持的格式: + +| 格式 | 示例 | 匹配规则 | +| --- | --- | --- | +| 精确 IPv4 | `1.2.3.4` | 仅匹配该 IP | +| IPv4 CIDR | `10.0.0.0/8` | 匹配该网段内所有 IP | +| 精确域名 | `api.example.com` | 仅匹配该域名 | +| 通配符子域名 | `*.example.com` | 匹配一级子域名,如 `foo.example.com`,不匹配 `example.com` 或 `a.b.example.com` |
docs/docs/de/get-started/installation/env.md+23 −1 modified@@ -456,4 +456,26 @@ yarn cross-env \ INIT_ROOT_PASSWORD=admin123 \ INIT_ROOT_NICKNAME="Super Admin" \ nocobase install -``` \ No newline at end of file +``` +## Umgebungsvariablen anderer Plugins + +### SERVER_REQUEST_WHITELIST + +Whitelist der erlaubten Ziele für serverseitige ausgehende HTTP-Anfragen, um SSRF-Angriffe (Server-Side Request Forgery) zu verhindern. Kommagetrennte Liste aus exakten IPs, CIDR-Bereichen, exakten Hostnamen und einstufigen Platzhalter-Subdomains. + +```bash +SERVER_REQUEST_WHITELIST=1.2.3.4,10.0.0.0/8,api.example.com,*.trusted.com +``` + +**Gilt für**: Workflow-Knoten „HTTP-Anfrage" und benutzerdefinierte Anfrage-Aktionsschaltflächen. Relative Pfade (Aufrufe der NocoBase-API selbst) sind nicht betroffen. + +**Nicht konfiguriert**: Alle `http`/`https`-Anfragen sind erlaubt (bisheriges Verhalten). **Konfiguriert**: Nur Anfragen, deren Host einem Whitelist-Eintrag entspricht, sind erlaubt; nicht übereinstimmende Anfragen führen zu einem Fehler. + +Unterstützte Formate: + +| Format | Beispiel | Trifft zu auf | +| --- | --- | --- | +| Exakte IPv4 | `1.2.3.4` | Nur diese IP | +| IPv4 CIDR | `10.0.0.0/8` | Alle IPs im Subnetz | +| Exakter Hostname | `api.example.com` | Nur dieser Hostname | +| Platzhalter-Subdomain | `*.example.com` | Eine Subdomain-Ebene, z. B. `foo.example.com`; **nicht** `example.com` oder `a.b.example.com` |
docs/docs/en/get-started/installation/env.md+21 −0 modified@@ -464,3 +464,24 @@ Workflow JavaScript node available modules list. For details, see "[JavaScript N ### WORKFLOW_LOOP_LIMIT Maximum loop count limit for workflow loop nodes. For details, see "[Loop Node](/workflow/nodes/loop#WORKFLOW_LOOP_LIMIT)". + +### SERVER_REQUEST_WHITELIST + +Whitelist of allowed targets for server-initiated outbound HTTP requests, used to prevent SSRF (Server-Side Request Forgery) attacks. Accepts a comma-separated list of exact IPs, CIDR ranges, exact hostnames, and single-level wildcard subdomains. + +```bash +SERVER_REQUEST_WHITELIST=1.2.3.4,10.0.0.0/8,api.example.com,*.trusted.com +``` + +**Applies to**: Workflow "HTTP Request" nodes and Custom Request action buttons. Relative-path requests (calls to the NocoBase API itself) are not affected. + +**When not set**: All `http`/`https` outbound requests are allowed (existing behaviour). **When set**: Only requests whose host matches a whitelist entry are permitted; non-matching requests will raise an error. + +Supported formats: + +| Format | Example | Matches | +| --- | --- | --- | +| Exact IPv4 | `1.2.3.4` | That IP only | +| IPv4 CIDR | `10.0.0.0/8` | All IPs in the subnet | +| Exact hostname | `api.example.com` | That hostname only | +| Wildcard subdomain | `*.example.com` | One subdomain level, e.g. `foo.example.com`; does **not** match `example.com` or `a.b.example.com` |
docs/docs/es/get-started/installation/env.md+23 −1 modified@@ -459,4 +459,26 @@ yarn cross-env \ INIT_ROOT_PASSWORD=admin123 \ INIT_ROOT_NICKNAME="Super Admin" \ nocobase install -``` \ No newline at end of file +``` +## Variables de entorno de otros plugins + +### SERVER_REQUEST_WHITELIST + +Lista blanca de destinos permitidos para solicitudes HTTP salientes iniciadas desde el servidor, utilizada para prevenir ataques SSRF (Server-Side Request Forgery). Acepta una lista separada por comas de IPs exactas, rangos CIDR, nombres de host exactos y subdominios con comodín de un solo nivel. + +```bash +SERVER_REQUEST_WHITELIST=1.2.3.4,10.0.0.0/8,api.example.com,*.trusted.com +``` + +**Aplica a**: Nodos de "Solicitud HTTP" en flujos de trabajo y botones de acción de solicitud personalizada. Las solicitudes con ruta relativa (llamadas a la propia API de NocoBase) no se ven afectadas. + +**Sin configurar**: Se permiten todas las solicitudes `http`/`https` salientes (comportamiento existente). **Configurado**: Solo se permiten solicitudes cuyo host coincida con una entrada de la lista blanca; las solicitudes que no coincidan generarán un error. + +Formatos admitidos: + +| Formato | Ejemplo | Coincide con | +| --- | --- | --- | +| IPv4 exacta | `1.2.3.4` | Solo esa IP | +| IPv4 CIDR | `10.0.0.0/8` | Todas las IPs de la subred | +| Nombre de host exacto | `api.example.com` | Solo ese nombre de host | +| Subdominio comodín | `*.example.com` | Un nivel de subdominio, p. ej. `foo.example.com`; **no** coincide con `example.com` ni `a.b.example.com` |
docs/docs/fr/get-started/installation/env.md+23 −1 modified@@ -457,4 +457,26 @@ yarn cross-env \ INIT_ROOT_PASSWORD=admin123 \ INIT_ROOT_NICKNAME="Super Admin" \ nocobase install -``` \ No newline at end of file +``` +## Variables d'environnement des autres plugins + +### SERVER_REQUEST_WHITELIST + +Liste blanche des cibles autorisées pour les requêtes HTTP sortantes initiées côté serveur, afin de prévenir les attaques SSRF (Server-Side Request Forgery). Accepte une liste séparée par des virgules d'IPs exactes, de plages CIDR, de noms d'hôtes exacts et de sous-domaines génériques à un seul niveau. + +```bash +SERVER_REQUEST_WHITELIST=1.2.3.4,10.0.0.0/8,api.example.com,*.trusted.com +``` + +**S'applique à** : Les nœuds « Requête HTTP » dans les workflows et les boutons d'action de requête personnalisée. Les requêtes avec chemin relatif (appels à l'API NocoBase elle-même) ne sont pas affectées. + +**Non configuré** : Toutes les requêtes `http`/`https` sortantes sont autorisées (comportement existant). **Configuré** : Seules les requêtes dont l'hôte correspond à une entrée de la liste blanche sont autorisées ; les requêtes non correspondantes génèrent une erreur. + +Formats pris en charge : + +| Format | Exemple | Correspond à | +| --- | --- | --- | +| IPv4 exacte | `1.2.3.4` | Uniquement cette IP | +| IPv4 CIDR | `10.0.0.0/8` | Toutes les IPs du sous-réseau | +| Nom d'hôte exact | `api.example.com` | Uniquement ce nom d'hôte | +| Sous-domaine générique | `*.example.com` | Un niveau de sous-domaine, ex. `foo.example.com` ; **pas** `example.com` ni `a.b.example.com` |
docs/docs/ja/get-started/installation/env.md+23 −1 modified@@ -459,4 +459,26 @@ yarn cross-env \ INIT_ROOT_PASSWORD=admin123 \ INIT_ROOT_NICKNAME="Super Admin" \ nocobase install -``` \ No newline at end of file +``` +## 他のプラグインが提供する環境変数 + +### SERVER_REQUEST_WHITELIST + +SSRF(サーバーサイドリクエストフォージェリ)攻撃を防ぐための、サーバーから送信される HTTP リクエストの許可先ホワイトリスト。カンマ区切りで、正確な IP アドレス・CIDR 範囲・正確なホスト名・単一レベルのワイルドカードサブドメインを指定できます。 + +```bash +SERVER_REQUEST_WHITELIST=1.2.3.4,10.0.0.0/8,api.example.com,*.trusted.com +``` + +**適用範囲**:ワークフローの「HTTP リクエスト」ノードおよびカスタムリクエストアクションボタン。相対パスのリクエスト(NocoBase 自身の API 呼び出し)は対象外です。 + +**未設定時**:すべての `http`/`https` リクエストを許可(既存の動作)。**設定時**:ホワイトリストに一致するホストへのリクエストのみ許可し、一致しないリクエストはエラーになります。 + +サポートされる形式: + +| 形式 | 例 | マッチ対象 | +| --- | --- | --- | +| 正確な IPv4 | `1.2.3.4` | その IP のみ | +| IPv4 CIDR | `10.0.0.0/8` | サブネット内のすべての IP | +| 正確なホスト名 | `api.example.com` | そのホスト名のみ | +| ワイルドカードサブドメイン | `*.example.com` | 1 レベルのサブドメイン(例:`foo.example.com`)。`example.com` や `a.b.example.com` は**不一致** |
docs/docs/ko/get-started/installation/env.md+23 −1 modified@@ -456,4 +456,26 @@ yarn cross-env \ INIT_ROOT_PASSWORD=admin123 \ INIT_ROOT_NICKNAME="Super Admin" \ nocobase install -``` \ No newline at end of file +``` +## 다른 플러그인이 제공하는 환경 변수 + +### SERVER_REQUEST_WHITELIST + +SSRF(서버 측 요청 위조) 공격을 방지하기 위한 서버 발신 HTTP 요청 허용 대상 화이트리스트입니다. 쉼표로 구분된 정확한 IP, CIDR 범위, 정확한 호스트명, 단일 레벨 와일드카드 서브도메인을 지정할 수 있습니다. + +```bash +SERVER_REQUEST_WHITELIST=1.2.3.4,10.0.0.0/8,api.example.com,*.trusted.com +``` + +**적용 범위**: 워크플로우 "HTTP 요청" 노드 및 커스텀 요청 액션 버튼. 상대 경로 요청(NocoBase 자체 API 호출)은 영향을 받지 않습니다. + +**미설정 시**: 모든 `http`/`https` 외부 요청이 허용됩니다(기존 동작). **설정 시**: 화이트리스트 항목과 일치하는 호스트로의 요청만 허용되며, 일치하지 않는 요청은 오류가 발생합니다. + +지원 형식: + +| 형식 | 예시 | 매칭 대상 | +| --- | --- | --- | +| 정확한 IPv4 | `1.2.3.4` | 해당 IP만 | +| IPv4 CIDR | `10.0.0.0/8` | 서브넷 내 모든 IP | +| 정확한 호스트명 | `api.example.com` | 해당 호스트명만 | +| 와일드카드 서브도메인 | `*.example.com` | 1단계 서브도메인(예: `foo.example.com`). `example.com` 또는 `a.b.example.com`은 **불일치** |
docs/docs/pt/get-started/installation/env.md+23 −1 modified@@ -456,4 +456,26 @@ yarn cross-env \ INIT_ROOT_PASSWORD=admin123 \ INIT_ROOT_NICKNAME="Super Admin" \ nocobase install -``` \ No newline at end of file +``` +## Variáveis de ambiente de outros plugins + +### SERVER_REQUEST_WHITELIST + +Lista de permissões de destinos para requisições HTTP de saída iniciadas pelo servidor, usada para prevenir ataques SSRF (Server-Side Request Forgery). Aceita uma lista separada por vírgulas de IPs exatos, intervalos CIDR, nomes de host exatos e subdomínios curinga de um único nível. + +```bash +SERVER_REQUEST_WHITELIST=1.2.3.4,10.0.0.0/8,api.example.com,*.trusted.com +``` + +**Aplica-se a**: Nós de "Requisição HTTP" em workflows e botões de ação de requisição personalizada. Requisições com caminho relativo (chamadas à própria API do NocoBase) não são afetadas. + +**Sem configuração**: Todas as requisições `http`/`https` de saída são permitidas (comportamento existente). **Configurado**: Apenas requisições cujo host corresponda a uma entrada da lista de permissões são permitidas; requisições sem correspondência geram um erro. + +Formatos suportados: + +| Formato | Exemplo | Corresponde a | +| --- | --- | --- | +| IPv4 exato | `1.2.3.4` | Apenas esse IP | +| IPv4 CIDR | `10.0.0.0/8` | Todos os IPs na sub-rede | +| Nome de host exato | `api.example.com` | Apenas esse nome de host | +| Subdomínio curinga | `*.example.com` | Um nível de subdomínio, ex. `foo.example.com`; **não** corresponde a `example.com` ou `a.b.example.com` |
docs/docs/ru/get-started/installation/env.md+23 −1 modified@@ -457,4 +457,26 @@ yarn cross-env \ INIT_ROOT_PASSWORD=admin123 \ INIT_ROOT_NICKNAME="Super Admin" \ nocobase install -``` \ No newline at end of file +``` +## Переменные окружения других плагинов + +### SERVER_REQUEST_WHITELIST + +Белый список разрешённых адресатов для исходящих HTTP-запросов на стороне сервера, используется для защиты от атак SSRF (Server-Side Request Forgery). Принимает список через запятую: точные IP-адреса, диапазоны CIDR, точные имена хостов и одноуровневые поддомены с подстановочным символом. + +```bash +SERVER_REQUEST_WHITELIST=1.2.3.4,10.0.0.0/8,api.example.com,*.trusted.com +``` + +**Применяется к**: узлам «HTTP-запрос» в рабочих процессах и кнопкам действий «Пользовательский запрос». Запросы с относительным путём (вызовы собственного API NocoBase) не затрагиваются. + +**Не задано**: все исходящие запросы по `http`/`https` разрешены (текущее поведение). **Задано**: разрешены только запросы, чей хост соответствует записи в белом списке; несовпадающие запросы возвращают ошибку. + +Поддерживаемые форматы: + +| Формат | Пример | Соответствует | +| --- | --- | --- | +| Точный IPv4 | `1.2.3.4` | Только этот IP | +| IPv4 CIDR | `10.0.0.0/8` | Все IP в подсети | +| Точное имя хоста | `api.example.com` | Только это имя хоста | +| Поддомен с подстановочным символом | `*.example.com` | Один уровень поддомена, напр. `foo.example.com`; **не** совпадает с `example.com` или `a.b.example.com` |
.env.example+8 −0 modified@@ -95,3 +95,11 @@ ENCRYPTION_FIELD_KEY= NOCOBASE_PKG_USERNAME= # service platform password NOCOBASE_PKG_PASSWORD= + +################# SECURITY ################# + +# Comma-separated whitelist of allowed outbound HTTP request targets (IPs, CIDR ranges, hostnames, wildcard domains). +# When set, server-side HTTP requests (e.g. workflow request nodes, custom request actions) are only allowed to the listed hosts. +# When not set, all outbound http/https requests are allowed. +# Examples: 1.2.3.4,10.0.0.0/8,api.example.com,*.trusted.com +# SERVER_REQUEST_WHITELIST=
packages/core/utils/package.json+2 −0 modified@@ -5,6 +5,8 @@ "types": "./lib/index.d.ts", "license": "Apache-2.0", "dependencies": { + "axios": "^1.7.0", + "ipaddr.js": "^1.9.1", "@budibase/handlebars-helpers": "0.14.0", "@hapi/topo": "^6.0.0", "@rc-component/mini-decimal": "^1.1.0",
packages/core/utils/src/index.ts+1 −0 modified@@ -46,5 +46,6 @@ export * from './variable-usage'; export * from './wrap-middleware'; export * from './run-sql'; export * from './liquidjs'; +export * from './server-request'; export { lodash }; //
packages/core/utils/src/server-request.ts+161 −0 added@@ -0,0 +1,161 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * SSRF protection utilities for server-side HTTP requests. + * + * Configure allowed outbound request targets via the SERVER_REQUEST_WHITELIST + * environment variable. Accepts a comma-separated list of: + * - Exact IPv4 addresses: 1.2.3.4 + * - IPv4 CIDR ranges: 10.0.0.0/8 + * - Exact IPv6 addresses: ::1 + * - IPv6 CIDR ranges: fc00::/7 + * - Exact hostnames: api.example.com + * - Wildcard subdomains: *.example.com (single level only) + * + * Example: + * SERVER_REQUEST_WHITELIST=1.2.3.4,10.0.0.0/8,::1,fc00::/7,api.example.com,*.trusted.com + * + * When not set, all requests are allowed (preserves existing behaviour). + * When set, only requests whose host matches an entry are permitted. + * + * Note: only http and https URL schemes are ever accepted, regardless of the + * whitelist configuration. + */ + +import ipaddr from 'ipaddr.js'; +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; + +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); + +/** + * Match a hostname (already confirmed to be a valid IP address) against a + * whitelist entry that may be an exact IP or a CIDR range (IPv4 or IPv6). + * IPv4-mapped IPv6 addresses (::ffff:1.2.3.4) are normalised to plain IPv4 + * before comparison so that v4 CIDR entries also cover mapped addresses. + */ +function matchesIpEntry(hostname: string, entry: string): boolean { + try { + let addr: ipaddr.IPv4 | ipaddr.IPv6 = ipaddr.parse(hostname); + if (addr.kind() === 'ipv6' && (addr as ipaddr.IPv6).isIPv4MappedAddress()) { + addr = (addr as ipaddr.IPv6).toIPv4Address(); + } + + if (entry.includes('/')) { + const cidr = ipaddr.parseCIDR(entry); + // parseCIDR returns [IPv4, number] | [IPv6, number]; match() overloads + // are not mutually compatible in the union, so we cast per kind. + if (addr.kind() !== cidr[0].kind()) return false; + if (addr.kind() === 'ipv4') { + return (addr as ipaddr.IPv4).match(cidr as [ipaddr.IPv4, number]); + } + return (addr as ipaddr.IPv6).match(cidr as [ipaddr.IPv6, number]); + } + + // Exact IP comparison — normalise both sides first + let entryAddr: ipaddr.IPv4 | ipaddr.IPv6 = ipaddr.parse(entry); + if (entryAddr.kind() === 'ipv6' && (entryAddr as ipaddr.IPv6).isIPv4MappedAddress()) { + entryAddr = (entryAddr as ipaddr.IPv6).toIPv4Address(); + } + return addr.toString() === entryAddr.toString(); + } catch { + return false; + } +} + +/** + * Match a hostname against a domain pattern. + * `*.example.com` matches exactly one subdomain level (e.g. `foo.example.com`) + * but not `example.com` itself or deeper levels like `a.b.example.com`. + */ +export function matchesDomainPattern(hostname: string, pattern: string): boolean { + const h = hostname.toLowerCase(); + const p = pattern.toLowerCase(); + if (p.startsWith('*.')) { + const suffix = p.slice(1); // ".example.com" + if (!h.endsWith(suffix) || h.length <= suffix.length) return false; + const prefix = h.slice(0, h.length - suffix.length); + return !prefix.includes('.'); + } + return h === p; +} + +function matchesEntry(hostname: string, entry: string): boolean { + const e = entry.trim(); + if (!e) return false; + return ipaddr.isValid(hostname) ? matchesIpEntry(hostname, e) : matchesDomainPattern(hostname, e); +} + +/** + * Validate a URL against the SERVER_REQUEST_WHITELIST environment variable. + * + * Throws an error if: + * - The URL scheme is not http or https. + * - SERVER_REQUEST_WHITELIST is set and the host does not match any entry. + * + * Silently returns for relative URLs (no scheme) so that internal API calls + * that use a relative path are not affected. + * + * Prefer using {@link serverRequest} over calling this directly. + */ +export function checkUrlAgainstWhitelist(url?: string): void { + if (!url) return; + + // Relative URLs have no scheme — they resolve to the same server, skip check. + if (!url.includes('://')) return; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + // Malformed URL — let the HTTP client surface its own error. + return; + } + + if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) { + throw new Error( + `URL scheme "${parsed.protocol.replace(':', '')}" is not allowed. Only http and https are permitted.`, + ); + } + + const whitelist = process.env.SERVER_REQUEST_WHITELIST; + if (!whitelist || !whitelist.trim()) return; + + const entries = whitelist + .split(',') + .map((e) => e.trim()) + .filter(Boolean); + if (entries.length === 0) return; + + // WHATWG URL serialises IPv6 addresses with brackets: "[::1]" → "::1" + const { hostname } = parsed; + const host = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; + + for (const entry of entries) { + if (matchesEntry(host, entry)) return; + } + + throw new Error( + `Outbound request to "${host}" is blocked. Add it to SERVER_REQUEST_WHITELIST to allow this request.`, + ); +} + +/** + * Drop-in replacement for `axios.request()` with built-in SSRF protection. + * + * Validates `config.url` against {@link checkUrlAgainstWhitelist} before + * forwarding to axios. Use this instead of calling axios directly for all + * server-initiated outbound HTTP requests. + */ +export async function serverRequest<T = any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> { + // Check config.url (before any baseURL combination) so that relative paths + // pointing to the same server are not subject to the whitelist. + checkUrlAgainstWhitelist(config.url); + return axios.request<T>(config); +}
packages/core/utils/src/__tests__/server-request.test.ts+166 −0 added@@ -0,0 +1,166 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { checkUrlAgainstWhitelist, matchesDomainPattern } from '../server-request'; + +// IP/CIDR matching is delegated to ipaddr.js (a mature, well-tested library). +// We only verify that our integration with it works correctly via +// checkUrlAgainstWhitelist end-to-end tests. + +describe('matchesDomainPattern', () => { + it('exact domain match (case-insensitive)', () => { + expect(matchesDomainPattern('api.example.com', 'api.example.com')).toBe(true); + expect(matchesDomainPattern('API.EXAMPLE.COM', 'api.example.com')).toBe(true); + expect(matchesDomainPattern('other.example.com', 'api.example.com')).toBe(false); + }); + + it('wildcard matches exactly one subdomain level', () => { + expect(matchesDomainPattern('foo.example.com', '*.example.com')).toBe(true); + expect(matchesDomainPattern('bar.baz.example.com', '*.example.com')).toBe(false); + expect(matchesDomainPattern('example.com', '*.example.com')).toBe(false); + }); +}); + +describe('checkUrlAgainstWhitelist', () => { + const ENV_KEY = 'SERVER_REQUEST_WHITELIST'; + let original: string | undefined; + + beforeEach(() => { + original = process.env[ENV_KEY]; + delete process.env[ENV_KEY]; + }); + + afterEach(() => { + if (original === undefined) { + delete process.env[ENV_KEY]; + } else { + process.env[ENV_KEY] = original; + } + }); + + // ── no whitelist ────────────────────────────────────────────────────────── + + it('no whitelist: allows any http/https URL', () => { + expect(() => checkUrlAgainstWhitelist('http://10.0.0.1/secret')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('https://169.254.169.254/meta-data/')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('http://[::1]/admin')).not.toThrow(); + }); + + it('always blocks non-http/https schemes regardless of whitelist', () => { + expect(() => checkUrlAgainstWhitelist('file:///etc/passwd')).toThrow(/scheme.*file.*not allowed/i); + expect(() => checkUrlAgainstWhitelist('ftp://files.example.com/data')).toThrow(/scheme.*ftp.*not allowed/i); + }); + + it('allows relative URLs (same-server calls)', () => { + expect(() => checkUrlAgainstWhitelist('/api/users:list')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist(undefined as any)).not.toThrow(); + }); + + // ── IPv4 ────────────────────────────────────────────────────────────────── + + it('whitelist: allows exact IPv4', () => { + process.env[ENV_KEY] = '1.2.3.4'; + expect(() => checkUrlAgainstWhitelist('http://1.2.3.4/api')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('http://1.2.3.5/api')).toThrow(/blocked/i); + }); + + it('whitelist: allows IPv4 in CIDR range', () => { + process.env[ENV_KEY] = '10.0.0.0/8'; + expect(() => checkUrlAgainstWhitelist('http://10.20.30.40/api')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('http://11.0.0.1/api')).toThrow(/blocked/i); + }); + + it('whitelist: blocks AWS metadata endpoint', () => { + process.env[ENV_KEY] = 'api.example.com'; + expect(() => checkUrlAgainstWhitelist('http://169.254.169.254/latest/meta-data/')).toThrow(/blocked/i); + }); + + it('whitelist: blocks loopback 127.x', () => { + process.env[ENV_KEY] = 'api.example.com'; + expect(() => checkUrlAgainstWhitelist('http://127.0.0.1:8080/admin')).toThrow(/blocked/i); + }); + + // ── IPv6 ────────────────────────────────────────────────────────────────── + + it('whitelist: allows exact IPv6', () => { + process.env[ENV_KEY] = '2001:db8::1'; + expect(() => checkUrlAgainstWhitelist('http://[2001:db8::1]/api')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('http://[2001:db8::2]/api')).toThrow(/blocked/i); + }); + + it('whitelist: allows IPv6 in CIDR range', () => { + process.env[ENV_KEY] = '2001:db8::/32'; + expect(() => checkUrlAgainstWhitelist('http://[2001:db8::1]/api')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('http://[2001:db9::1]/api')).toThrow(/blocked/i); + }); + + it('whitelist: blocks IPv6 loopback ::1', () => { + process.env[ENV_KEY] = 'api.example.com'; + expect(() => checkUrlAgainstWhitelist('http://[::1]/admin')).toThrow(/blocked/i); + }); + + it('no whitelist: IPv6 loopback allowed (backward compat)', () => { + delete process.env[ENV_KEY]; + expect(() => checkUrlAgainstWhitelist('http://[::1]/admin')).not.toThrow(); + }); + + it('whitelist: IPv4-mapped IPv6 matches IPv4 CIDR entry', () => { + // ::ffff:10.0.0.1 should match a "10.0.0.0/8" entry + process.env[ENV_KEY] = '10.0.0.0/8'; + expect(() => checkUrlAgainstWhitelist('http://[::ffff:a00:1]/api')).not.toThrow(); + }); + + it('whitelist: allows IPv6 unique-local range', () => { + process.env[ENV_KEY] = 'fc00::/7'; + expect(() => checkUrlAgainstWhitelist('http://[fd00::1]/api')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('http://[fe80::1]/api')).toThrow(/blocked/i); + }); + + // ── domain / wildcard ───────────────────────────────────────────────────── + + it('whitelist: allows exact domain', () => { + process.env[ENV_KEY] = 'api.example.com'; + expect(() => checkUrlAgainstWhitelist('https://api.example.com/v1')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('https://evil.example.com/v1')).toThrow(/blocked/i); + }); + + it('whitelist: wildcard allows one subdomain level only', () => { + process.env[ENV_KEY] = '*.trusted.com'; + expect(() => checkUrlAgainstWhitelist('https://service.trusted.com/v1')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('https://trusted.com/v1')).toThrow(/blocked/i); + expect(() => checkUrlAgainstWhitelist('https://a.b.trusted.com/v1')).toThrow(/blocked/i); + }); + + // ── multi-entry & edge cases ────────────────────────────────────────────── + + it('whitelist: multi-entry — allows when any entry matches', () => { + process.env[ENV_KEY] = '1.2.3.4,api.example.com,*.trusted.com'; + expect(() => checkUrlAgainstWhitelist('http://1.2.3.4/api')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('https://api.example.com/v1')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('https://foo.trusted.com/v1')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('http://10.0.0.1/secret')).toThrow(/blocked/i); + }); + + it('whitelist: ignores blank entries and surrounding whitespace', () => { + process.env[ENV_KEY] = ' , ,api.example.com, '; + expect(() => checkUrlAgainstWhitelist('https://api.example.com/v1')).not.toThrow(); + expect(() => checkUrlAgainstWhitelist('https://other.com/v1')).toThrow(/blocked/i); + }); + + it('whitelist: scheme check still applies even when host would match', () => { + process.env[ENV_KEY] = '1.2.3.4'; + expect(() => checkUrlAgainstWhitelist('file:///etc/passwd')).toThrow(/scheme.*file.*not allowed/i); + }); + + it('whitelist: relative URLs always allowed regardless of whitelist', () => { + process.env[ENV_KEY] = 'api.example.com'; + expect(() => checkUrlAgainstWhitelist('/api/users:list')).not.toThrow(); + }); +});
packages/plugins/@nocobase/plugin-action-custom-request/src/server/actions/send.ts+4 −2 modified@@ -8,7 +8,7 @@ */ import { Context, Next } from '@nocobase/actions'; -import { parse } from '@nocobase/utils'; +import { parse, serverRequest } from '@nocobase/utils'; import { appendArrayColumn } from '@nocobase/evaluators'; import Application from '@nocobase/server'; @@ -172,6 +172,8 @@ export async function send(this: CustomRequestPlugin, ctx: Context, next: Next) const axiosRequestConfig = { baseURL: ctx.origin, ...options, + // safeRequest checks this url value (before baseURL combination) so that + // relative paths pointing to the same server are not subject to the whitelist. url: getParsedValue(url, variables), headers: { Authorization: 'Bearer ' + ctx.getBearerToken(), @@ -195,7 +197,7 @@ export async function send(this: CustomRequestPlugin, ctx: Context, next: Next) ); try { - const res = await axios(axiosRequestConfig); + const res = await serverRequest(axiosRequestConfig); this.logger.info(`custom-request:send:${filterByTk} success`); ctx.body = res.data; if (res.headers['content-disposition']) {
packages/plugins/@nocobase/plugin-action-custom-request/src/server/__tests__/actions.test.ts+72 −0 modified@@ -177,4 +177,76 @@ describe('actions', () => { expect(expect.arrayContaining(params.c)).toMatchObject([user.id, user.id, user.id]); }); }); + + describe('SSRF protection via SERVER_REQUEST_WHITELIST', () => { + const ENV_KEY = 'SERVER_REQUEST_WHITELIST'; + let savedEnv: string | undefined; + + beforeAll(async () => { + await repo.create({ + values: { + key: 'ssrf-relative', + options: { + url: '/customRequests:test', + method: 'GET', + }, + }, + }); + await repo.create({ + values: { + key: 'ssrf-external', + options: { + url: 'http://169.254.169.254/latest/meta-data/', + method: 'GET', + }, + }, + }); + }); + + beforeEach(() => { + savedEnv = process.env[ENV_KEY]; + }); + + afterEach(() => { + if (savedEnv === undefined) { + delete process.env[ENV_KEY]; + } else { + process.env[ENV_KEY] = savedEnv; + } + }); + + test('no whitelist: relative URL (same-server call) is allowed', async () => { + delete process.env[ENV_KEY]; + const res = await resource.send({ filterByTk: 'ssrf-relative' }); + expect(res.status).toBe(200); + }); + + test('whitelist set: relative URL (same-server call) is still allowed', async () => { + process.env[ENV_KEY] = 'api.example.com'; + const res = await resource.send({ filterByTk: 'ssrf-relative' }); + expect(res.status).toBe(200); + }); + + test('whitelist set: external absolute URL to unlisted host is blocked', async () => { + process.env[ENV_KEY] = 'api.example.com'; + const res = await resource.send({ filterByTk: 'ssrf-external' }); + // checkUrlAgainstWhitelist throws, which becomes a 500 + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + test('no whitelist: non-http scheme is always blocked', async () => { + delete process.env[ENV_KEY]; + await repo.create({ + values: { + key: 'ssrf-file-scheme', + options: { + url: 'file:///etc/passwd', + method: 'GET', + }, + }, + }); + const res = await resource.send({ filterByTk: 'ssrf-file-scheme' }); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + }); });
packages/plugins/@nocobase/plugin-ai/src/server/utils.ts+4 −3 modified@@ -10,8 +10,7 @@ import { Model } from '@nocobase/database'; import path from 'path'; import fs from 'fs'; -import axios from 'axios'; -import { getDateVars, parse, parseFilter } from '@nocobase/utils'; +import { getDateVars, parse, serverRequest } from '@nocobase/utils'; import { Context } from '@nocobase/actions'; export function sendSSEError(ctx: Context, error: Error | string, errorName?: string) { @@ -72,7 +71,9 @@ export async function encodeFile(ctx: Context, url: string) { const referer = ctx.get('referer') || ''; const ua = ctx.get('user-agent') || ''; ctx.log.trace('llm message encode file', { url, referer, ua }); - const response = await axios.get(url, { + const response = await serverRequest({ + method: 'get', + url, responseType: 'arraybuffer', headers: { referer,
packages/plugins/@nocobase/plugin-workflow-request/src/server/RequestInstruction.ts+3 −2 modified@@ -7,13 +7,14 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import axios, { AxiosRequestConfig } from 'axios'; +import { AxiosRequestConfig } from 'axios'; import { trim } from 'lodash'; import { Processor, Instruction, JOB_STATUS, FlowNodeModel, IJob } from '@nocobase/plugin-workflow'; import PluginFileManagerServer, { AttachmentModel } from '@nocobase/plugin-file-manager'; import { Application } from '@nocobase/server'; import { Readable } from 'stream'; +import { serverRequest } from '@nocobase/utils'; export interface Header { name: string; @@ -114,7 +115,7 @@ async function request(config: RequestInstructionConfig, app: Application) { } const transformer = getContentTypeTransformer(contentType, app); - return axios.request({ + return serverRequest({ url: trim(url), method, headers,
packages/plugins/@nocobase/plugin-workflow-request/src/server/__tests__/instruction.test.ts+92 −0 modified@@ -793,4 +793,96 @@ describe('workflow > instructions > request', () => { expect(result).not.toHaveProperty('config'); }); }); + + describe('SSRF protection via SERVER_REQUEST_WHITELIST', () => { + const ENV_KEY = 'SERVER_REQUEST_WHITELIST'; + let savedEnv: string | undefined; + + beforeEach(() => { + savedEnv = process.env[ENV_KEY]; + }); + + afterEach(() => { + if (savedEnv === undefined) { + delete process.env[ENV_KEY]; + } else { + process.env[ENV_KEY] = savedEnv; + } + }); + + it('no whitelist: allows request to any host', async () => { + delete process.env[ENV_KEY]; + const { status } = await instruction.test({ + url: api.URL_DATA, + method: 'GET', + contentType: 'application/json', + }); + expect(status).toBe(JOB_STATUS.RESOLVED); + }); + + it('whitelist set: blocks request to unlisted host', async () => { + process.env[ENV_KEY] = '192.0.2.1'; // TEST-NET, not localhost + const { status, result } = await instruction.test({ + url: api.URL_DATA, // localhost:{port} + method: 'GET', + contentType: 'application/json', + }); + expect(status).toBe(JOB_STATUS.FAILED); + expect(result.message).toMatch(/blocked/i); + }); + + it('whitelist set: allows request to whitelisted host', async () => { + // api.URL_DATA uses "localhost" as hostname + process.env[ENV_KEY] = `localhost`; + const { status } = await instruction.test({ + url: api.URL_DATA, + method: 'GET', + contentType: 'application/json', + }); + expect(status).toBe(JOB_STATUS.RESOLVED); + }); + + it('whitelist set: ignoreFail still resolves when blocked', async () => { + process.env[ENV_KEY] = '192.0.2.1'; + const { status, result } = await instruction.test({ + url: api.URL_DATA, + method: 'GET', + contentType: 'application/json', + ignoreFail: true, + }); + expect(status).toBe(JOB_STATUS.RESOLVED); + expect(result.message).toMatch(/blocked/i); + }); + + it('whitelist set: blocks metadata endpoint (SSRF attack target)', async () => { + process.env[ENV_KEY] = 'api.example.com'; + const { status, result } = await instruction.test({ + url: 'http://169.254.169.254/latest/meta-data/', + method: 'GET', + contentType: 'application/json', + }); + expect(status).toBe(JOB_STATUS.FAILED); + expect(result.message).toMatch(/blocked/i); + }); + + it('whitelist set: async workflow node fails job when host is blocked', async () => { + process.env[ENV_KEY] = '192.0.2.1'; + + await workflow.createNode({ + type: 'request', + config: { + url: api.URL_DATA, + method: 'GET', + } as RequestInstructionConfig, + }); + + await PostRepo.create({ values: { title: 'ssrf-test' } }); + await sleep(500); + + const [execution] = await workflow.getExecutions(); + const [job] = await execution.getJobs(); + expect(job.status).toBe(JOB_STATUS.FAILED); + expect(job.result.message).toMatch(/blocked/i); + }); + }); });
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/nocobase/nocobase/commit/2853368243ed07339c62c548b7d475f4eeaada59nvdPatch
- github.com/nocobase/nocobase/pull/9079nvdExploitPatch
- github.com/nocobase/nocobase/security/advisories/GHSA-mvvv-v22x-xqwpnvdExploitVendor Advisory
- github.com/advisories/GHSA-mvvv-v22x-xqwpghsaADVISORY
- github.com/nocobase/nocobase/releases/tag/v2.0.37nvdRelease Notes
- nvd.nist.gov/vuln/detail/CVE-2026-40346ghsa
News mentions
0No linked articles in our index yet.