VYPR
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

1

Patches

1
2853368243ed

fix(plugin-workflow-request): add server-request to package request security logic (#9079)

https://github.com/nocobase/nocobaseJunyiApr 11, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.