Ekspresi reguler (regex) di JavaScript menyimpan perangkap tersembunyi yang sering merusak keandalan pipeline CI/CD dan performa server. Dua masalah paling umum adalah flaky test akibat mutasi internal lastIndex dan serangan Regular Expression Denial of Service (ReDoS) akibat catastrophic backtracking. Artikel ini membahas akar masalah teknis kedua isu tersebut serta menyediakan strategi verifikasi berbasis modul bawaan Node.js.

Akar Masalah: Stateful Regex dan lastIndex Drift

Secara default, objek RegExp di JavaScript bersifat stateless. Namun, ketika flag global (/g) atau sticky (/y) diaktifkan, objek tersebut berubah menjadi stateful. Properti lastIndex pada instance regex akan menyimpan indeks karakter terakhir tempat kecocokan ditemukan pada pemanggilan sebelumnya.

Jika instance regex yang sama digunakan kembali (misalnya dideklarasikan di lingkup modul atau di-cache sebagai konstanta), eksekusi berikutnya pada string baru atau string yang sama tidak dimulai dari indeks 0, melainkan dari posisi lastIndex sebelumnya. Pola ini memicu kegagalan intermiten (flaky) saat request concurrent atau test suite dijalankan berulang kali.

Pembuktian Bug lastIndex Drift

Perhatikan contoh pengujian berikut menggunakan runner native node:test dan modul assertion node:assert/strict:

// regex-state.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';

// Bug: Flag /g menyebabkan regex mempertahankan status lastIndex antar-pemanggilan
const MODULE_SCOPED_EMAIL_REGEX = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/gi;

test('lastIndex drift memicu kegagalan assert validasi yang identik', () => {
  const email = '[email protected]';

  // Pemanggilan 1: Berhasil, lastIndex bergeser ke akhir string (26)
  const firstAttempt = MODULE_SCOPED_EMAIL_REGEX.test(email);
  assert.equal(firstAttempt, true);
  assert.equal(MODULE_SCOPED_EMAIL_REGEX.lastIndex, 26);

  // Pemanggilan 2: Gagal karena pencarian dimulai dari indeks 26!
  const secondAttempt = MODULE_SCOPED_EMAIL_REGEX.test(email);
  // Bug muncul di sini: secondAttempt bernilai false
  assert.equal(secondAttempt, true, 'Flaky failure: regex gagal pada string yang sama');
});

Solusinya langsung: hapus flag /g jika tujuannya hanya validasi format tunggal (test()). Jika pencarian multi-match dibutuhkan, buat instance baru di dalam fungsi lokal atau gunakan String.prototype.matchAll() yang mengisolasi iterasi tanpa mengotori instance global.

Anatomi Catastrophic Backtracking (ReDoS)

Engine regex JavaScript (seperti V8 Irregexp) menggunakan algoritma NFA (Nondeterministic Finite Automaton) konvensional dengan mekanisme backtracking. Kerentanan ReDoS terjadi saat pola mengandung kuantifikasi bersarang atau alternatif yang tumpang tindih (ambiguous overlaps), misalnya:

  • (a+)+$
  • ([a-zA-Z0-9]+)*$
  • (a|aa)+$

Ketika engine menerima string input yang diawali banyak karakter cocok tetapi diakhiri oleh karakter yang gagal validasi (payload evil), engine akan mengevaluasi seluruh permutasi jalur kecocokan yang mungkin secara rekursif. Kompleksitas waktu melonjak dari polinomial $O(n)$ menjadi eksponensial $O(2^n)$, memblokir Node.js event loop hingga pemrosesan mencapai batas CPU 100%.

Fuzzing Input dan Boundary Testing untuk ReDoS

Memeriksa regex hanya dengan data valid (“happy path”) tidak akan menemukan ReDoS. Kita harus melakukan property-based boundary testing dan fuzzing menggunakan payload adversarial yang dirancang untuk memicu backtracking terburuk: repetisi panjang karakter valid yang diakhiri karakter pemutus.

Berikut implementasi runner test Node.js native untuk memverifikasi apakah suatu regex mengalami degradasi performa eksponensial:

// regex-redos.test.mjs
import test from 'node:test';
import assert from 'node:assert/strict';

// Pola vulnerable terhadap catastrophic backtracking
const VULNERABLE_REGEX = /^([a-zA-Z0-9_]+)*$/;

// Pola aman: batasi cakupan tanpa nesting repetitif
const SAFE_REGEX = /^[a-zA-Z0-9_]*$/;

function* generateAdversarialPayloads(baseChar = 'a', mismatchChar = '!') {
  // Menguji peningkatan beban dari panjang 10 hingga 30 karakter
  for (let length = 10; length <= 30; length += 5) {
    yield {
      length,
      payload: baseChar.repeat(length) + mismatchChar,
    };
  }
}

test('Fuzzing eksekusi regex mendeteksi ledakan latensi', () => {
  const MAX_ALLOWED_MS = 15; // Ambang batas eksekusi aman per validasi

  for (const { length, payload } of generateAdversarialPayloads()) {
    const start = performance.now();
    
    // Eksekusi regex terhadap string fuzzing
    SAFE_REGEX.test(payload);
    
    const duration = performance.now() - start;

    assert.ok(
      duration < MAX_ALLOWED_MS,
      `ReDoS terdeteksi pada panjang input ${length}: butuh waktu ${duration.toFixed(2)}ms`
    );
  }
});

Jika SAFE_REGEX diganti dengan VULNERABLE_REGEX, durasi eksekusi pada panjang 25–30 karakter akan langsung menembus ratusan milidetik dan menggagalkan assertion.

Pencegahan Otomatis dan Batas Penggunaan Regex

Lakukan mitigasi terstruktur di seluruh lifecycle pengembangan dengan langkah-langkah berikut:

  1. Gunakan Linter Statis: Pasang plugin ESLint eslint-plugin-regexp. Aturan seperti regexp/no-super-linear-backtracking dan regexp/no-misleading-capturing-group mampu mendeteksi potensi ReDoS pada waktu kompilasi sebelum kode masuk ke repository.
  2. Tetapkan Timeout Eksekusi: Jika regex yang dijalankan berasal dari user input atau regex dinamis yang kompleks, jalankan evaluasi di dalam Worker Thread terpisah via node:worker_threads dan matikan thread menggunakan worker.terminate() jika batas waktu (misal 50ms) terlampaui.
  3. Ganti dengan Deterministik Parser: Regex bukan alat serbaguna. Jika format input memiliki struktur bertingkat (seperti parsing URL, markdown, nested JSON, atau domain path), tinggalkan regex. Gunakan metode parsing deterministik standar seperti URL API, method string native (indexOf, slice, startsWith), atau state-machine parser linear $O(n)$ seperti PEG.js.