Anatomi Masalah: Sifat Webmention dan Risiko Backend

Webmention adalah rekomendasi W3C untuk notifikasi terdesentralisasi antar-situs. Ketika seseorang menautkan artikel Anda di situs mereka, server mereka mengirimkan HTTP POST ke endpoint Webmention Anda dengan membawa dua parameter: source (URL mereka) dan target (URL Anda). Server Anda kemudian wajib memverifikasi bahwa source benar-benar memuat tautan ke target sebelum menyimpannya sebagai mention, komentar, atau repost.

Mengabaikan arsitektur pemrosesan yang tepat menimbulkan dua kerentanan kritis:

  • Denial of Service (DoS) melalui HTTP hanging: Melakukan HTTP GET ke source secara sinkron di dalam thread web request akan memblokir worker koneksi. Penyerang dapat memasukkan ribuan source lambat (Slowloris/Tarpit) hingga thread pool habis.
  • Server-Side Request Forgery (SSRF): Menjadikan source sebagai target pengambilan data mentah memungkinkan penyerang menunjuk alamat internal seperti http://169.254.169.254/ (metadata instance cloud) atau subnet LAN pribadi http://10.0.0.1/.

Kontrak API: Respon 202 Accepted dan Validasi Awal

Spesifikasi Webmention menginstruksikan penerima untuk segera mengembalikan status HTTP 202 Accepted jika payload sintaksis valid dan pemrosesan dilakukan secara asinkron.

Validasi tahap pertama yang wajib selesai sebelum status 202 dikembalikan meliputi:

  1. Payload bertipe application/x-www-form-urlencoded.
  2. Parameter source dan target terisi dan berupa URL absolut berprotokol http atau https.
  3. URL target merujuk ke domain lokal milik Anda dan entitas resource yang dituju memang valid di database.
  4. Parameter source tidak sama persis dengan target (mencegah self-referencing loops).

Contoh Handler Endpoint (Express.js / Node.js)

import { Request, Response } from 'express';
import { createHash } from 'node:crypto';
import { mentionQueue } from './queue.js';
import { isLocalHostTarget } from './url-validator.js';

export async function webmentionHandler(req: Request, res: Response): Promise<void> {
  const { source, target } = req.body;

  if (!source || !target) {
    res.status(400).json({ error: 'Missing source or target' });
    return;
  }

  let sourceUrl: URL;
  let targetUrl: URL;
  try {
    sourceUrl = new URL(source);
    targetUrl = new URL(target);
  } catch {
    res.status(400).json({ error: 'Invalid URL format' });
    return;
  }

  if (!['http:', 'https:'].includes(sourceUrl.protocol) || !['http:', 'https:'].includes(targetUrl.protocol)) {
    res.status(400).json({ error: 'Unsupported protocol' });
    return;
  }

  if (sourceUrl.href === targetUrl.href) {
    res.status(400).json({ error: 'Source and target must differ' });
    return;
  }

  if (!isLocalHostTarget(targetUrl)) {
    res.status(400).json({ error: 'Target URL is not hosted on this server' });
    return;
  }

  // Idempotency check via Hash
  const hashKey = createHash('sha256').update(`${sourceUrl.href}:${targetUrl.href}`).digest('hex');
  
  // Kirim ke antrean background
  await mentionQueue.add('verify-webmention', {
    source: sourceUrl.href,
    target: targetUrl.href,
    idempotencyKey: hashKey,
  }, {
    jobId: hashKey, // Cegah duplikasi job aktif
    removeOnComplete: true,
  });

  res.status(202).send('Accepted');
}

Idempotensi Payload: Mengatasi Duplicate Mentions dan Spam

Pengirim Webmention sering melakukan pengiriman berulang ketika artikel di-update atau ketika koneksi jaringan timeout. Tanpa idempotensi, entitas Webmention dapat tercatat ganda pada feed komentar.

Kunci idempotensi didefinisikan secara deterministik menggunakan operasi ringkasan pesan (hash):

Idempotency Key = SHA256(normalize(source) + ":" + normalize(target))

Normalisasi mencakup penurunan huruf domain (lowercasing host), penghapusan trailing slash yang redundan, dan pembersihan tracking query strings (seperti utm_*). Database harus menerapkan unique constraint gabungan pada (source_hash, target_hash) atau menyimpan kunci di in-memory store (Redis) dengan status siklus hidup:

  • PENDING: Webmention sedang berada di antrean verifikasi. Payload baru yang identik dibatalkan dengan mengembalikan 202 tanpa menjadwalkan job baru.
  • VERIFIED: Source valid dan tautan telah dipublikasikan. Permintaan baru akan memicu re-verifikasi update konten.
  • FAILED: Verifikasi gagal (misal link dihapus). Diberikan batas waktu retensi (TTL) agar tidak membebani server oleh bot retry.

Mitigasi SSRF pada Outbound Fetching

Saat worker asinkron memvalidasi source, worker tersebut melakukan HTTP GET ke server eksternal. Penyerang dapat memasukkan URL yang mengarah ke intranet atau IP metadata cloud (misal AWS/GCP 169.254.169.254). Selain itu, teknik DNS Rebinding dapat melewati validasi string URL konvensional.

Validasi yang aman mewajibkan pengujian IP address langsung saat resolusi DNS, bukan sekadar memeriksa hostname.

Daftar Rentang IP Terlarang

  • Loopback: 127.0.0.0/8, ::1/128
  • Private Subnets: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  • Link-Local / Cloud Metadata: 169.254.0.0/16, fe80::/10
  • Carrier-Grade NAT: 100.64.0.0/10

Implementasi Custom HTTP Agent Anti-SSRF

Contoh berikut menggunakan library ipaddr.js pada Node.js untuk mengevaluasi alamat IP hasil resolusi DNS sebelum soket TCP terbentuk:

import http from 'node:http';
import https from 'node:https';
import dns from 'node:dns/promises';
import ipaddr from 'ipaddr.js';

function isForbiddenIp(ipString: string): boolean {
  const addr = ipaddr.parse(ipString);
  const range = addr.range();
  
  const blockedRanges = [
    'loopback',
    'private',
    'linkLocal',
    'carrierGradeNat',
    'uniqueLocal',
    'unspecified',
  ];

  return blockedRanges.includes(range);
}

// Custom lookup untuk pinning IP aman dan memblokir DNS Rebinding
export async function safeLookup(hostname: string, options: any, callback: Function) {
  try {
    const addresses = await dns.lookup(hostname, { all: true });
    
    for (const record of addresses) {
      if (isForbiddenIp(record.address)) {
        return callback(new Error(`SSRF Blocked: IP ${record.address} is forbidden`), null, null);
      }
    }

    // Ambil alamat pertama yang lolos inspeksi
    const selected = addresses[0];
    callback(null, selected.address, selected.family);
  } catch (err) {
    callback(err, null, null);
  }
}

Peringatan Keamanan: Jangan mengandalkan modul bawaan fetch yang mengikuti HTTP Redirects secara buta (redirect: 'follow'). Pengirim dapat mengarahkan source domain publik ke internal IP via HTTP 302. Tangani redirect manual: batas maksimal 3 hop, dan setiap URL tujuan wajib melalui safeLookup kembali.

Strategi Antrean Asinkron & Verifikasi HTML

Setelah koneksi aman terbangun, worker memproses verifikasi konten dengan aturan ketat.

1. Pembatasan Resource Download

  • Gunakan AbortSignal.timeout(5000) untuk membatasi durasi request maksimal 5 detik.
  • Batasi ukuran respons (streaming limit) maksimal 1 MB hingga 2 MB. Jangan memuat dokumen HTML tak terbatas ke dalam memori untuk menghindari Zip-bomb atau memory exhaustion.
  • Hanya parse respons dengan header Content-Type: text/html atau application/xhtml+xml.

2. Algoritma Pencarian Tautan Target

Parse dokumen HTML menggunakan parser parser berbasis stream atau parser DOM cepat (seperti cheerio / htmlparser2). Periksa elemen berikut:

  • Tautan standar: <a href="TARGET_URL">
  • Tautan microformats2: <a class="u-in-reply-to" href="TARGET_URL"> atau u-like-of, u-repost-of.

Jika tautan ditemukan, simpan mention dan ekstrak metadata pengirim (author name, avatar, bio). Jika tidak ditemukan, anggap mention tidak valid atau telah ditarik (unmention).

3. Konfigurasi Worker Queue dan Exponential Backoff

Jika server pengirim mengalami 5xx server error atau koneksi reset, worker harus mencoba kembali secara terukur (retry policy) tanpa membanjiri target:

PercobaanDelay IntervalTindakan Jika Gagal
Retry 130 DetikMasuk ulang antrean worker
Retry 25 MenitMasuk ulang antrean worker
Retry 330 MenitMasuk ulang antrean worker
Final-Tandai status FAILED, catat log metrik

Kesimpulan

Penerimaan Webmention yang aman menuntut isolasi ketat antara penerimaan webhook dan eksekusi jaringan outbound. Mengembalikan status 202 Accepted langsung menjaga ketersediaan thread web, penggunaan hash idempotensi mengeliminasi payload ganda, dan validasi IP tingkat DNS mencegah celah SSRF serta eksploitasi infrastruktur internal.