Hydration mismatch pada Server-Side Rendering (SSR) sering kali disederhanakan sebagai inkonsistensi markup biasa. Pada arsitektur Node.js berskala besar, gejala ini dapat menandakan kerentanan kritis: cross-request state pollution. Pola ini analog dengan perilaku use-after-free (seperti pola insiden CVE-2026-57589), di mana data sesi berprivilese (misalnya data Admin) yang tersisa di memori runtime dialokasikan tanpa sengaja ke markup HTML pengguna publik (Guest).

Gejala Klinis pada Aplikasi Frontend

Kebocoran konteks SSR memicu sejumlah anomali spesifik pada runtime browser:

  • Hydration Error pada Log Konsol: Peringatan seperti Hydration failed because the initial UI does not match what was rendered on the server atau Text content did not match server-rendered HTML.
  • UI Flash Komponen Berprivilese: Pengguna guest melihat tombol "Admin Panel" atau data profil sensitif selama fraksi detik pada HTML mentah server, sebelum akhirnya elemen tersebut menghilang setelah JavaScript client menyelesaikan hidrasi.
  • Kegagalan Rekonsiliasi DOM: Browser membongkar paksa dan merender ulang seluruh sub-tree DOM karena server mengirimkan struktur DOM rute terproteksi sementara client hanya memuat state anonim.

Akar Masalah: Polusi Context pada Node.js SSR Runtime

Node.js menggunakan model eksekusi single-threaded non-blocking yang mengeksekusi banyak request HTTP dalam loop bersama. Dua pola implementasi fatal yang memicu polusi context antar-request adalah:

  1. Singleton Store di Modul Scope: Menyimpan state request pada objek global atau variabel modul level root. Saat request concurrent terjadi, referensi state tertimpa.
  2. AsyncLocalStorage Leak saat Abort: Request client yang diputus di tengah jalan (misalnya koneksi client terputus atau timeout) dapat meninggalkan referensi context yang tidak dibersihkan jika eksekusi async terus berjalan dan merujuk store yang telah usang.

Contoh Kode Rentan (State Pollution)

// server.js - POLA RENTAN
import express from 'express';
import { renderToString } from './renderer.js';

const app = express();

// Modul-level singleton: BENCANA CONCURRENCY
let currentRequestContext = null;

export const getContext = () => currentRequestContext;

app.get('*', async (req, res) => {
  // State request A ditimpa oleh request B jika request A masih tertahan await
  currentRequestContext = {
    user: req.headers['x-auth-user'] ? JSON.parse(req.headers['x-auth-user']) : null
  };

  const html = await renderToString(req.url);
  res.send(html);
});

Jika Request A (Admin) memasuki jeda I/O asinkron, lalu Request B (Guest) tiba dan menimpa currentRequestContext, atau sebaliknya Request A selesai dieksekusi saat Request B mulai diproses tanpa isolasi, maka HTML Request B dapat dirender menggunakan data autentikasi milik Request A.

Solusi 1: Isolasi Ketat Request Context dengan AsyncLocalStorage

Gunakan API native Node.js AsyncLocalStorage secara benar. Konteks harus dibungkus dalam boundary run eksekusi masing-masing request sehingga context mati bersama siklus request tersebut.

// context.js
import { AsyncLocalStorage } from 'node:async_hooks';

export const requestStorage = new AsyncLocalStorage();

export function getRequestContext() {
  const store = requestStorage.getStore();
  if (!store) {
    throw new Error('RequestContext diakses di luar execution context aktif');
  }
  return store;
}

Terapkan middleware penyedia konteks di entry point server:

// server.js - IMPLEMENTASI AMAN
import express from 'express';
import { requestStorage } from './context.js';
import { renderAppToStream } from './renderer.js';

const app = express();

app.use((req, res, next) => {
  const context = {
    user: req.user || null,
    requestId: req.headers['x-request-id'] || crypto.randomUUID(),
    signal: req.signal // AbortSignal dari HTTP incoming message
  };

  // Pastikan eksekusi hilir sepenuhnya di dalam boundary context
  requestStorage.run(context, () => {
    next();
  });
});

Solusi 2: Lifecycle Cleanup dan Penanganan AbortSignal

Saat request concurrent di-abort oleh client (misal: navigasi cepat pengguna), rendering engine SSR harus menghentikan streaming HTML seketika untuk mencegah kebocoran alokasi buffer atau memori stale.

// renderer.js
import { getRequestContext } from './context.js';

export async function renderToStringWithAbort(req, res) {
  const { signal } = getRequestContext();

  return new Promise((resolve, reject) => {
    if (signal.aborted) {
      return reject(new Error('Request dibatalkan sebelum render dimulai'));
    }

    const abortHandler = () => {
      // Hentikan alur render, cegah pemakaian context yang sudah basi
      reject(new Error('Rendering di-abort oleh client'));
    };

    signal.addEventListener('abort', abortHandler, { once: true });

    executeSSRRenderPipeline()
      .then(resolve)
      .catch(reject)
      .finally(() => {
        signal.removeEventListener('abort', abortHandler);
      });
  });
}

Solusi 3: Pengamanan Hydration Boundary Sisi Klien

Jangan pernah menggunakan DOM SSR yang terkompromi sebagai sumber kebenaran (source of truth) otorisasi di browser. Batasi hak akses menggunakan verifikasi ulang state pada hydration boundary.

// ProtectedBoundary.jsx
import React, { createContext, useContext, useEffect, useState } from 'react';

const AuthContext = createContext(null);

export function ProtectedBoundary({ children, fallback = null }) {
  const auth = useContext(AuthContext);
  const [isClientReady, setIsClientReady] = useState(false);

  useEffect(() => {
    // Pastikan validasi auth dijalankan di client post-hydration
    setIsClientReady(true);
  }, []);

  // Cegah render komponen sensitif jika token client belum terverifikasi,
  // mengabaikan struktur HTML SSR yang salah kirim akibat stale context.
  if (!isClientReady || !auth?.token) {
    return fallback;
  }

  return children;
}
Catatan Keamanan: Jangan gunakan suppressHydrationWarning untuk meredam mismatch pada komponen yang melibatkan identitas pengguna atau hak akses. Atribut tersebut hanya menutupi gejala tanpa menyelesaikan kebocoran data pada HTML mentah.

Verifikasi: Assertion Concurrency Anti-Leak

Jalankan uji konkurensi untuk membuktikan context tidak bocor di antara request admin dan guest:

// test-concurrency.js
import assert from 'node:assert';
import { AsyncLocalStorage } from 'node:async_hooks';

const storage = new AsyncLocalStorage();

async function simulateRequest(user, delayMs) {
  return storage.run({ user }, async () => {
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return storage.getStore().user;
  });
}

// Simulasi Request A (Admin, lambat) dan Request B (Guest, cepat)
const [resA, resB] = await Promise.all([
  simulateRequest('ADMIN_USER', 50),
  simulateRequest('GUEST_USER', 10)
]);

assert.strictEqual(resA, 'ADMIN_USER', 'Context admin harus tetap ADMIN_USER');
assert.strictEqual(resB, 'GUEST_USER', 'Context guest tidak boleh terpolusi');
console.log('Verifikasi berhasil: Isolasi context berjalan benar.');