Akar Masalah: Desinkronisasi Dynamic Block pada Headless SSR

Arsitektur headless yang mengonsumsi WordPress Core melalui REST API atau GraphQL sering mengalami hydration mismatch saat merender dynamic block Gutenberg di sisi server (SSR). Masalah ini terjadi ketika pohon DOM yang dihasilkan server Node.js tidak identik dengan struktur virtual DOM yang direkonstruksi oleh klien (React/Next.js).

Pemicu utamanya adalah inkonsistensi serialisasi atribut block. WordPress menyimpan data block dinamis dalam komentar HTML terstruktur:

<!-- wp:custom/interactive-card {"cardId":42,"title":"Dashboard\u003c/script>"} -->
<div class="wp-block-custom-interactive-card">Fallback Content</div>
<!-- /wp:custom/interactive-card -->

Jika SSR parser mengekstrak JSON tersebut tanpa validasi tipe yang ketat, lalu menyuntikkannya langsung ke window state klien (misalnya __NEXT_DATA__ atau window.__INITIAL_STATE__), dua risiko muncul bersamaan: State Injection (melalui penutupan script tag prematur) dan kegagalan rekonsiliasi hidrasi DOM (karena tipe data yang bermutasi antara server dan browser).

Vektor Serangan: State Injection melalui Atribut Block

Pola serangan yang dianalisis dalam berbagai audit keamanan WordPress Core (seperti dokumentasi eksploitasi wp2shell) menargetkan celah parsing pada filter atribut block. Pengguna level Contributor atau plugin pihak ketiga dapat menyuntikkan payload string berbahaya ke dalam JSON atribut block.

Ketika frontend SSR menjalankan JSON.stringify(rawAttributes) ke dalam tag <script> untuk hidrasi klien, string yang memuat urutan karakter </script><script> akan mengeksekusi payload di browser sebelum hidrasi berjalan. Eksekusi ini memanipulasi state global atau merusak payload JSON, yang memicu React melempar error hidrasi dan melakukan fallback ke client-side render penuh, membuka celah Cross-Site Scripting (XSS).

1. Audit Parsing Atribut Block pada Pipeline SSR

Gunakan parser resmi @wordpress/block-serialization-default-parser atau AST traversal kustom yang secara eksplisit membekukan objek untuk mencegah polusi prototipe (Prototype Pollution).

import { parse } from '@wordpress/block-serialization-default-parser';

interface RawBlock {
  blockName: string | null;
  attrs: Record<string, unknown> | null;
  innerBlocks: RawBlock[];
  innerHTML: string;
}

export function safeParseBlocks(rawHtml: string): RawBlock[] {
  const parsed = parse(rawHtml);
  
  return parsed.map(function sanitizeNode(block): RawBlock {
    const safeAttrs = Object.create(null);
    
    if (block.attrs && typeof block.attrs === 'object') {
      for (const [key, value] of Object.entries(block.attrs)) {
        // Cegah prototype pollution vector (__proto__, constructor)
        if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
          continue;
        }
        safeAttrs[key] = value;
      }
    }

    return {
      blockName: block.blockName,
      attrs: safeAttrs,
      innerBlocks: block.innerBlocks.map(sanitizeNode),
      innerHTML: block.innerHTML,
    };
  });
}

2. Validasi Schema Ketat pada Hydration Boundary

Data dari pipeline WordPress harus divalidasi dengan runtime schema validator seperti Zod sebelum dikirimkan ke komponen React. Hal ini memastikan atribut server dan klien memiliki tipe data yang seragam.

import { z } from 'zod';

export const InteractiveCardSchema = z.object({
  cardId: z.number().int().positive(),
  title: z.string().max(255).transform((val) => val.replace(/[<>]/g, '')),
  isActive: z.boolean().default(false),
  metadata: z.record(z.string(), z.string()).optional(),
});

export type InteractiveCardProps = z.infer<typeof InteractiveCardSchema>;

export function validateBlockProps(rawAttrs: unknown): InteractiveCardProps | null {
  const result = InteractiveCardSchema.safeParse(rawAttrs);
  if (!result.success) {
    // Log schema violation pada server monitor, gagalkan parsing state
    console.error('Invalid block attribute payload:', result.error.format());
    return null;
  }
  return result.data;
}

3. Sanitasi Serialisasi JSON State Injection

Jangan pernah menggunakan JSON.stringify() standar langsung di dalam template SSR. Karakter <, >, /, \u2028, dan \u2029 harus di-escape ke format heksadesimal Unicode.

export function safeSerializeState(state: Record<string, unknown>): string {
  const UNSAFE_CHARS_REGEX = /[<>\/\u2028\u2029]/g;
  const ESCAPE_LOOKUP: Record<string, string> = {
    '<': '\\u003C',
    '>': '\\u003E',
    '/': '\\u002F',
    '\u2028': '\\u2028',
    '\u2029': '\\u2029',
  };

  return JSON.stringify(state).replace(UNSAFE_CHARS_REGEX, (match) => ESCAPE_LOOKUP[match]);
}

Suntikkan output tersebut ke dalam tag script dengan atribut context yang aman:

// Implementasi pada Document Body SSR
<script
  type="application/json"
  id="__WP_BLOCK_STATE__"
  dangerouslySetInnerHTML={{
    __html: safeSerializeState(validatedState),
  }}
/>

4. Isolasi Hydration Fallback Tanpa Merusak DOM

Jika validasi atribut block gagal di sisi klien atau server, jangan mengembalikan tag HTML yang berbeda secara sepihak di salah satu runtime. Ketidakcocokan tag (misalnya server merender <div>, klien merender null) merusak struktur rekonsiliasi hydration.

Gunakan komponen fallback pembungkus statis yang konsisten:

import React from 'react';
import { InteractiveCardProps } from './schema';

interface BlockWrapperProps {
  props: InteractiveCardProps | null;
  children: (validProps: InteractiveCardProps) => React.ReactElement;
}

export const SafeDynamicBlock: React.FC<BlockWrapperProps> = ({ props, children }) => {
  // Struktur DOM pembungkus tetap identik di SSR dan Client
  if (!props) {
    return (
      <div 
        className="wp-block-fallback-boundary"
        data-hydration-status="quarantined"
        style={{ display: 'contents' }}
      >
        <!-- Block attributes corrupted or failed validation -->
      </div>
    );
  }

  return (
    <div className="wp-block-active-boundary" style={{ display: 'contents' }}>
      {children(props)}
    </div>
  );
};

Ringkasan Prosedur Mitigasi

  1. Isolasi Parser: Netralkan komentar block sebelum masuk ke runtime rendering SSR. Hindari pembacaan atribut liar tanpa batasan traversal.
  2. Validasi Boundary: Tetapkan runtime schema (Zod) sebelum data block diteruskan ke komponen React SSR. Tolak payload yang tidak memenuhi kontrak tipe data.
  3. Serialisasi Aman: Escape karakter delimiter HTML pada JSON script injection untuk menutup celah state injection.
  4. Konsistensi Fallback: Pertahankan arsitektur DOM tree yang simetris ketika terjadi error parsing atribut guna mencegah hydration mismatch total pada aplikasi headless.