From 6feafcff6c2dbafe206161c5d09e38e1d36af66f Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 20 Apr 2026 20:27:08 +0200 Subject: [PATCH] fix: socket issue (#10777) --- README.md | 11 ++ docs/es/pages/advanced/request-config.md | 23 ++++ docs/fr/pages/advanced/request-config.md | 23 ++++ docs/pages/advanced/request-config.md | 23 ++++ docs/zh/pages/advanced/request-config.md | 23 ++++ index.d.cts | 1 + index.d.ts | 1 + lib/adapters/http.js | 28 +++++ lib/core/mergeConfig.js | 1 + tests/unit/adapters/http.test.js | 137 +++++++++++++++++++++++ 10 files changed, 271 insertions(+) diff --git a/README.md b/README.md index de30efc5..9dbcd7ac 100644 --- a/README.md +++ b/README.md @@ -600,8 +600,19 @@ These are the available config options for making requests. Only the `url` is re // e.g. '/var/run/docker.sock' to send requests to the docker daemon. // Only either `socketPath` or `proxy` can be specified. // If both are specified, `socketPath` is used. + // + // Security: when `socketPath` is set, hostname/port of the URL are ignored, + // which bypasses hostname-based SSRF protections. Never derive `socketPath` + // from untrusted input. Use `allowedSocketPaths` (below) to restrict accepted + // socket paths for defense-in-depth. socketPath: null, // default + // `allowedSocketPaths` restricts which `socketPath` values are accepted. + // Accepts a string or array of strings. Entries and the incoming socketPath + // are compared after path.resolve(). A mismatch throws AxiosError with code + // `ERR_BAD_OPTION_VALUE`. When null/undefined, no restriction is applied. + allowedSocketPaths: null, // default + // `transport` determines the transport method that will be used to make the request. // If defined, it will be used. Otherwise, if `maxRedirects` is 0, // the default `http` or `https` library will be used, depending on the protocol specified in `protocol`. diff --git a/docs/es/pages/advanced/request-config.md b/docs/es/pages/advanced/request-config.md index cb207247..3b261d1c 100644 --- a/docs/es/pages/advanced/request-config.md +++ b/docs/es/pages/advanced/request-config.md @@ -160,6 +160,28 @@ La función `beforeRedirect` te permite modificar la solicitud antes de que sea La propiedad `socketPath` define un socket UNIX que se usará en lugar de una conexión TCP. Por ejemplo, `/var/run/docker.sock` para enviar solicitudes al daemon de Docker. Solo se puede especificar `socketPath` o `proxy`. Si ambos se especifican, se usa `socketPath`. +:::warning Seguridad +Cuando se establece `socketPath`, el hostname y el puerto de la URL se ignoran y axios se comunica directamente con el socket Unix indicado. Si cualquier parte de la configuración de la solicitud proviene de entrada del usuario (por ejemplo, en un proxy o manejador de webhooks que reenvía opciones), un atacante puede inyectar `socketPath` para redirigir el tráfico a sockets locales privilegiados como `/var/run/docker.sock`, `/run/containerd/containerd.sock` o `/run/systemd/private`, eludiendo por completo las protecciones SSRF basadas en hostname (CWE-918). Filtra la configuración recibida desde entradas no confiables y/o restringe las rutas de socket aceptadas con `allowedSocketPaths` (ver más abajo). +::: + +### `allowedSocketPaths` + +Restringe qué rutas de socket pueden usarse a través de `socketPath`. Acepta un string o un array de strings. Cuando está definido, axios resuelve el `socketPath` y lo compara con cada entrada (también resuelta); la solicitud se rechaza con un `AxiosError` de código `ERR_BAD_OPTION_VALUE` si no hay coincidencia. Si no se define (valor por defecto), `socketPath` se comporta igual que antes. + +```js +const client = axios.create({ + allowedSocketPaths: ['/var/run/docker.sock'] +}); + +// permitido +await client.get('http://localhost/v1.45/info', { socketPath: '/var/run/docker.sock' }); + +// rechazado — no está en la lista +await client.get('http://localhost/pods', { socketPath: '/var/run/kubelet.sock' }); +``` + +Un array vacío (`allowedSocketPaths: []`) bloquea todas las rutas de socket. + ### `transport` La propiedad `transport` define el transporte a usar para la solicitud. Es útil para hacer solicitudes sobre un protocolo diferente, como `http2`. @@ -313,6 +335,7 @@ La propiedad `maxRate` define el **ancho de banda** máximo (en bytes por segund } }, socketPath: null, + allowedSocketPaths: null, transport: undefined, httpAgent: new http.Agent({ keepAlive: true }), httpsAgent: new https.Agent({ keepAlive: true }), diff --git a/docs/fr/pages/advanced/request-config.md b/docs/fr/pages/advanced/request-config.md index 4fa9b8df..665a103f 100644 --- a/docs/fr/pages/advanced/request-config.md +++ b/docs/fr/pages/advanced/request-config.md @@ -160,6 +160,28 @@ La fonction `beforeRedirect` vous permet de modifier la requête avant qu'elle n La propriété `socketPath` définit un socket UNIX à utiliser à la place d'une connexion TCP. Par exemple `/var/run/docker.sock` pour envoyer des requêtes au daemon Docker. Seul `socketPath` ou `proxy` peut être spécifié. Si les deux sont spécifiés, `socketPath` est utilisé. +:::warning Sécurité +Lorsque `socketPath` est défini, le hostname et le port de l'URL de la requête sont ignorés et axios communique directement avec le socket Unix indiqué. Si une partie de la configuration de la requête provient d'une entrée utilisateur (par exemple dans un proxy ou un gestionnaire de webhooks qui transfère des options), un attaquant peut injecter `socketPath` pour rediriger le trafic vers des sockets locaux privilégiés tels que `/var/run/docker.sock`, `/run/containerd/containerd.sock` ou `/run/systemd/private`, contournant entièrement les protections SSRF basées sur le hostname (CWE-918). Filtrez la configuration provenant d'entrées non fiables et/ou restreignez les chemins de socket acceptés avec `allowedSocketPaths` (voir ci-dessous). +::: + +### `allowedSocketPaths` + +Restreint les chemins de socket pouvant être utilisés via `socketPath`. Accepte une chaîne ou un tableau de chaînes. Lorsqu'elle est définie, axios résout le `socketPath` et le compare à chaque entrée (également résolue) ; la requête est rejetée avec une `AxiosError` de code `ERR_BAD_OPTION_VALUE` s'il n'y a aucune correspondance. Lorsque non définie (par défaut), `socketPath` se comporte comme avant. + +```js +const client = axios.create({ + allowedSocketPaths: ['/var/run/docker.sock'] +}); + +// autorisé +await client.get('http://localhost/v1.45/info', { socketPath: '/var/run/docker.sock' }); + +// rejeté — pas dans la liste +await client.get('http://localhost/pods', { socketPath: '/var/run/kubelet.sock' }); +``` + +Un tableau vide (`allowedSocketPaths: []`) bloque tous les chemins de socket. + ### `transport` La propriété `transport` définit le transport à utiliser pour la requête. Utile pour effectuer des requêtes via un protocole différent, comme `http2`. @@ -313,6 +335,7 @@ La propriété `maxRate` définit la **bande passante** maximale (en octets par } }, socketPath: null, + allowedSocketPaths: null, transport: undefined, httpAgent: new http.Agent({ keepAlive: true }), httpsAgent: new https.Agent({ keepAlive: true }), diff --git a/docs/pages/advanced/request-config.md b/docs/pages/advanced/request-config.md index ab0503d8..eeee6801 100644 --- a/docs/pages/advanced/request-config.md +++ b/docs/pages/advanced/request-config.md @@ -160,6 +160,28 @@ The `beforeRedirect` function allows you to modify the request before it is redi The `socketPath` property defines a UNIX socket to use instead of a TCP connection. e.g. `/var/run/docker.sock` to send requests to the docker daemon. Only `socketPath` or `proxy` can be specified. If both are specified, `socketPath` is used. +:::warning Security +When `socketPath` is set, the hostname and port of the request URL are ignored and axios communicates directly with the specified Unix domain socket. If any part of the request config is derived from user input (for example, when forwarding or merging request options in a proxy/webhook handler), an attacker can inject `socketPath` to redirect traffic to privileged local sockets such as `/var/run/docker.sock`, `/run/containerd/containerd.sock`, or `/run/systemd/private` — bypassing hostname-based SSRF protections entirely (CWE-918). Strip or allowlist config keys from untrusted input, and/or restrict accepted socket paths with `allowedSocketPaths` (see below). +::: + +### `allowedSocketPaths` + +Restricts which socket paths may be used via `socketPath`. Accepts a string or an array of strings. When set, axios resolves the `socketPath` and compares it against each entry (also resolved); the request is rejected with `AxiosError` code `ERR_BAD_OPTION_VALUE` when there is no match. When unset (default), `socketPath` behaves as before. + +```js +const client = axios.create({ + allowedSocketPaths: ['/var/run/docker.sock'] +}); + +// allowed +await client.get('http://localhost/v1.45/info', { socketPath: '/var/run/docker.sock' }); + +// rejected — not in allowlist +await client.get('http://localhost/pods', { socketPath: '/var/run/kubelet.sock' }); +``` + +An empty array (`allowedSocketPaths: []`) blocks all socket paths. + ### `transport` The `transport` property defines the transport to use for the request. This is useful for making requests over a different protocol, such as `http2`. @@ -313,6 +335,7 @@ The `maxRate` property defines the maximum **bandwidth** (in bytes per second) f } }, socketPath: null, + allowedSocketPaths: null, transport: undefined, httpAgent: new http.Agent({ keepAlive: true }), httpsAgent: new https.Agent({ keepAlive: true }), diff --git a/docs/zh/pages/advanced/request-config.md b/docs/zh/pages/advanced/request-config.md index d21fadc6..e27242c3 100644 --- a/docs/zh/pages/advanced/request-config.md +++ b/docs/zh/pages/advanced/request-config.md @@ -160,6 +160,28 @@ `socketPath` 属性定义用于替代 TCP 连接的 UNIX 套接字路径,例如 `/var/run/docker.sock`,用于向 Docker 守护进程发送请求。`socketPath` 和 `proxy` 只能指定其中一个,如果两者都指定,则使用 `socketPath`。 +:::warning 安全提示 +设置 `socketPath` 后,请求 URL 中的主机名和端口将被忽略,axios 会直接与指定的 Unix 域套接字通信。如果请求配置中有任何部分来自用户输入(例如在转发或合并请求选项的代理/Webhook 处理程序中),攻击者可以注入 `socketPath` 将流量重定向到特权本地套接字,如 `/var/run/docker.sock`、`/run/containerd/containerd.sock` 或 `/run/systemd/private`,从而完全绕过基于主机名的 SSRF 防护(CWE-918)。应对来自不可信输入的配置进行过滤或仅允许特定键,并/或使用 `allowedSocketPaths`(见下文)限制接受的套接字路径。 +::: + +### `allowedSocketPaths` + +限制可通过 `socketPath` 使用的套接字路径。接受一个字符串或字符串数组。设置后,axios 会解析 `socketPath` 并与每个条目(同样解析后)比较;若无匹配,请求将以 `ERR_BAD_OPTION_VALUE` 错误码的 `AxiosError` 被拒绝。未设置(默认)时,`socketPath` 行为与以往一致。 + +```js +const client = axios.create({ + allowedSocketPaths: ['/var/run/docker.sock'] +}); + +// 允许 +await client.get('http://localhost/v1.45/info', { socketPath: '/var/run/docker.sock' }); + +// 拒绝 — 不在白名单中 +await client.get('http://localhost/pods', { socketPath: '/var/run/kubelet.sock' }); +``` + +空数组 (`allowedSocketPaths: []`) 会阻止所有套接字路径。 + ### `transport` `transport` 属性定义请求使用的传输方式,适用于通过不同协议(如 `http2`)发起请求的场景。 @@ -313,6 +335,7 @@ proxy: { } }, socketPath: null, + allowedSocketPaths: null, transport: undefined, httpAgent: new http.Agent({ keepAlive: true }), httpsAgent: new https.Agent({ keepAlive: true }), diff --git a/index.d.cts b/index.d.cts index 1a9e192a..b3839a8e 100644 --- a/index.d.cts +++ b/index.d.cts @@ -497,6 +497,7 @@ declare namespace axios { responseDetails: { headers: Record; statusCode: HttpStatusCode } ) => void; socketPath?: string | null; + allowedSocketPaths?: string | string[] | null; transport?: any; httpAgent?: any; httpsAgent?: any; diff --git a/index.d.ts b/index.d.ts index 7a9d87a8..4fb2f8ee 100644 --- a/index.d.ts +++ b/index.d.ts @@ -397,6 +397,7 @@ export interface AxiosRequestConfig { } ) => void; socketPath?: string | null; + allowedSocketPaths?: string | string[] | null; transport?: any; httpAgent?: any; httpsAgent?: any; diff --git a/lib/adapters/http.js b/lib/adapters/http.js index 5e596b3c..e3f22f7e 100755 --- a/lib/adapters/http.js +++ b/lib/adapters/http.js @@ -7,6 +7,7 @@ import http from 'http'; import https from 'https'; import http2 from 'http2'; import util from 'util'; +import { resolve as resolvePath } from 'path'; import followRedirects from 'follow-redirects'; import zlib from 'zlib'; import { VERSION } from '../env/data.js'; @@ -667,6 +668,33 @@ export default isHttpAdapterSupported && !utils.isUndefined(lookup) && (options.lookup = lookup); if (config.socketPath) { + if (typeof config.socketPath !== 'string') { + return reject(new AxiosError( + 'socketPath must be a string', + AxiosError.ERR_BAD_OPTION_VALUE, + config + )); + } + + if (config.allowedSocketPaths != null) { + const allowed = Array.isArray(config.allowedSocketPaths) + ? config.allowedSocketPaths + : [config.allowedSocketPaths]; + + const resolvedSocket = resolvePath(config.socketPath); + const isAllowed = allowed.some( + (entry) => typeof entry === 'string' && resolvePath(entry) === resolvedSocket + ); + + if (!isAllowed) { + return reject(new AxiosError( + `socketPath "${config.socketPath}" is not permitted by allowedSocketPaths`, + AxiosError.ERR_BAD_OPTION_VALUE, + config + )); + } + } + options.socketPath = config.socketPath; } else { options.hostname = parsed.hostname.startsWith('[') diff --git a/lib/core/mergeConfig.js b/lib/core/mergeConfig.js index 98b56e2d..2c4bf4ce 100644 --- a/lib/core/mergeConfig.js +++ b/lib/core/mergeConfig.js @@ -90,6 +90,7 @@ export default function mergeConfig(config1, config2) { httpsAgent: defaultToConfig2, cancelToken: defaultToConfig2, socketPath: defaultToConfig2, + allowedSocketPaths: defaultToConfig2, responseEncoding: defaultToConfig2, validateStatus: mergeDirectKeys, headers: (a, b, prop) => diff --git a/tests/unit/adapters/http.test.js b/tests/unit/adapters/http.test.js index f9421aba..f34ce6f2 100644 --- a/tests/unit/adapters/http.test.js +++ b/tests/unit/adapters/http.test.js @@ -4161,4 +4161,141 @@ describe('supports http with nodejs', () => { assert.strictEqual(socket.listenerCount('error'), baseErrorListenerCount); }); }); + + describe('socketPath security (GHSA-j96w-fp6f-pq6v)', () => { + function makeSocketPath() { + return path.join(os.tmpdir(), `axios-socketpath-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`); + } + + function startUnixServer(socketPath) { + return new Promise((resolveStart, rejectStart) => { + const server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, url: req.url })); + }); + try { fs.unlinkSync(socketPath); } catch (_) { /* noop */ } + server.once('error', rejectStart); + server.listen(socketPath, () => resolveStart(server)); + }); + } + + function stopUnixServer(server, socketPath) { + return new Promise((done) => { + server.close(() => { + try { fs.unlinkSync(socketPath); } catch (_) { /* noop */ } + done(); + }); + }); + } + + it('allows socketPath when no allowedSocketPaths is set (backwards compatible)', async () => { + const socketPath = makeSocketPath(); + const server = await startUnixServer(socketPath); + try { + const res = await axios.get('http://localhost/echo', { socketPath }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.data.ok, true); + } finally { + await stopUnixServer(server, socketPath); + } + }); + + it('allows socketPath when it matches an allowedSocketPaths string', async () => { + const socketPath = makeSocketPath(); + const server = await startUnixServer(socketPath); + try { + const res = await axios.get('http://localhost/echo', { + socketPath, + allowedSocketPaths: socketPath, + }); + assert.strictEqual(res.status, 200); + } finally { + await stopUnixServer(server, socketPath); + } + }); + + it('allows socketPath when it matches an entry in allowedSocketPaths array', async () => { + const socketPath = makeSocketPath(); + const server = await startUnixServer(socketPath); + try { + const res = await axios.get('http://localhost/echo', { + socketPath, + allowedSocketPaths: ['/var/run/does-not-exist.sock', socketPath], + }); + assert.strictEqual(res.status, 200); + } finally { + await stopUnixServer(server, socketPath); + } + }); + + it('rejects socketPath not in allowedSocketPaths', async () => { + await assert.rejects( + axios.get('http://localhost/echo', { + socketPath: '/var/run/docker.sock', + allowedSocketPaths: ['/tmp/allowed.sock'], + }), + (err) => { + assert.ok(err instanceof AxiosError); + assert.strictEqual(err.code, AxiosError.ERR_BAD_OPTION_VALUE); + assert.match(err.message, /allowedSocketPaths/); + return true; + } + ); + }); + + it('rejects socketPath attempting path traversal that escapes allowlist', async () => { + const allowedDir = path.join(os.tmpdir(), 'axios-allowed'); + const allowed = path.join(allowedDir, 'app.sock'); + await assert.rejects( + axios.get('http://localhost/echo', { + socketPath: path.join(allowedDir, '..', 'other.sock'), + allowedSocketPaths: [allowed], + }), + (err) => { + assert.strictEqual(err.code, AxiosError.ERR_BAD_OPTION_VALUE); + return true; + } + ); + }); + + it('treats relative and absolute allowedSocketPaths entries equivalently', async () => { + const socketPath = makeSocketPath(); + const server = await startUnixServer(socketPath); + try { + const relative = path.relative(process.cwd(), socketPath); + const res = await axios.get('http://localhost/echo', { + socketPath, + allowedSocketPaths: [relative], + }); + assert.strictEqual(res.status, 200); + } finally { + await stopUnixServer(server, socketPath); + } + }); + + it('rejects non-string socketPath', async () => { + await assert.rejects( + axios.get('http://localhost/echo', { socketPath: 12345 }), + (err) => { + assert.ok(err instanceof AxiosError); + assert.strictEqual(err.code, AxiosError.ERR_BAD_OPTION_VALUE); + assert.match(err.message, /socketPath must be a string/); + return true; + } + ); + }); + + it('empty allowedSocketPaths array blocks all socketPath values', async () => { + await assert.rejects( + axios.get('http://localhost/echo', { + socketPath: '/tmp/anything.sock', + allowedSocketPaths: [], + }), + (err) => { + assert.strictEqual(err.code, AxiosError.ERR_BAD_OPTION_VALUE); + return true; + } + ); + }); + }); });