Hardening queue worker bukan sekadar soal mencegah job gagal. Masalah utamanya adalah bagaimana memastikan worker tidak mengeksekusi payload berbahaya, patch tak tervalidasi, atau job yang secara semantik valid tetapi merusak sistem ketika diproses berulang, paralel, atau di luar urutan. Pada sistem terdistribusi, celah seperti ini sering muncul bukan karena satu bug besar, melainkan kombinasi dari validasi lemah, retry agresif, lock yang salah, dan worker yang terlalu dipercaya.

Jika Anda mengelola antrean berbasis Redis atau PostgreSQL, pendekatan yang aman biasanya mencakup beberapa lapisan: validasi payload sebelum enqueue, idempotency key untuk menahan duplikasi, distributed locking untuk mencegah eksekusi simultan yang berbahaya, visibility timeout yang benar, retry policy dengan backoff, dead-letter queue untuk isolasi kegagalan, serta isolasi worker dan audit trail untuk respons insiden. Artikel ini membahas pola tersebut secara praktis, termasuk trade-off performa dan contoh implementasinya.

Mengapa queue worker perlu di-hardening

Queue sering dianggap aman karena job diproses di belakang layar dan berasal dari sistem internal. Asumsi ini berbahaya. Dalam praktiknya, payload job bisa berasal dari:

  • API publik yang menaruh request ke queue setelah lolos autentikasi tetapi belum lolos validasi bisnis yang ketat.
  • Layanan internal lain yang salah konfigurasi atau sudah terkompromi.
  • Patch operasional atau task darurat yang diinjeksikan manual oleh operator.
  • Reprocessing dari dead-letter queue tanpa sanitasi ulang.

Ketika konteks industri sedang ramai oleh drop 0-day anonim dan proof-of-concept yang cepat beredar, risiko bukan hanya eksploit langsung ke aplikasi, tetapi juga penyisipan payload operasional yang “terlihat normal” dan lolos ke worker. Di titik itu, queue berubah dari mekanisme asinkron menjadi jalur eksekusi yang sangat dipercaya.

Tujuan hardening bukan membuat queue mustahil disalahgunakan, melainkan mengurangi blast radius, memperkecil peluang eksekusi payload jahat, dan membuat anomali cepat terlihat serta cepat dihentikan.

Model ancaman: apa yang sebenarnya bisa salah

1. Payload valid secara sintaks, berbahaya secara semantik

Contohnya job patch.apply berisi target host yang benar, tetapi checksum artefaknya tidak terdaftar atau sumbernya tidak diotorisasi. Secara bentuk JSON valid, tetapi aksi yang diwakilinya tidak boleh dijalankan.

2. Job dieksekusi lebih dari sekali

Pada sistem distributed queue, at-least-once delivery umum terjadi. Worker bisa crash setelah memproses job tetapi sebelum melakukan ack/remove. Tanpa idempotency, efek samping seperti pengiriman email, update saldo, atau patch host bisa terjadi berulang.

3. Job yang sama dikerjakan paralel

Dua worker bisa mengambil job yang menargetkan resource yang sama. Jika tidak ada lock per resource, hasilnya dapat berupa race condition, state korup, atau patch bertumpuk pada host yang sama.

4. Retry memperparah kerusakan

Retry yang langsung, tanpa batas, dan tanpa klasifikasi error dapat mengubah bug kecil menjadi serangan internal. Misalnya payload invalid terus gagal, tetapi worker tetap memukul cache, database, atau endpoint eksternal berkali-kali.

5. Worker memiliki akses terlalu luas

Jika worker bisa menulis ke semua key Redis, mengakses seluruh skema database, atau menjalankan command shell tanpa pembatasan, satu job berbahaya bisa berubah menjadi insiden lintas sistem.

Lapisan pertama: validasi payload sebelum enqueue

Prinsip pentingnya: jangan menunda validasi kritis sampai worker. Worker tetap harus memvalidasi ulang, tetapi pemeriksaan awal sebelum enqueue membantu menahan job buruk agar tidak memenuhi antrean, retry, dan observability pipeline.

Apa saja yang perlu divalidasi

  • Schema: field wajib, tipe data, enum aksi, batas panjang, format timestamp, ukuran payload.
  • Semantik bisnis: resource target ada, statusnya cocok, aksi diizinkan untuk aktor tersebut.
  • Integritas: checksum artefak, signature, versi manifest, sumber artefak yang diizinkan.
  • Keamanan: tolak field bebas yang nanti diteruskan ke shell, SQL, template, atau path file tanpa whitelist.

Untuk job tipe “patch”, lebih aman jika payload hanya membawa referensi immutable, bukan script mentah. Misalnya kirim artifact_id, sha256, dan target_group, bukan command arbitrer.

// Pseudocode validasi sebelum enqueue
function enqueuePatchJob(req) {
  assertEnum(req.action, ["patch.apply"])
  assertUuid(req.target_group_id)
  assertHex(req.artifact_sha256, 64)
  assertMaxLength(req.request_id, 128)

  const artifact = artifactStore.findBySha256(req.artifact_sha256)
  if (!artifact || artifact.status !== "approved") {
    throw new Error("artifact tidak terotorisasi")
  }

  if (!acl.canDeploy(req.actor_id, req.target_group_id)) {
    throw new Error("aktor tidak diizinkan")
  }

  queue.push({
    type: "patch.apply",
    request_id: req.request_id,
    target_group_id: req.target_group_id,
    artifact_id: artifact.id,
    artifact_sha256: artifact.sha256,
    created_by: req.actor_id
  })
}

Mengapa ini efektif? Karena queue hanya menerima pesan yang sudah dipersempit bentuk dan maknanya. Worker tidak lagi menafsirkan perintah bebas, tetapi mengeksekusi aksi dari kontrak yang terbatas.

Kesalahan umum

  • Hanya memvalidasi JSON schema, tanpa validasi status bisnis.
  • Mengandalkan validasi di UI, tetapi endpoint internal tetap menerima payload mentah.
  • Mengizinkan field “opsional” yang sebenarnya membuka jalur perilaku tak terduga, misalnya command, script, atau override=true.

Idempotency key dan deduplikasi: wajib untuk queue yang realistis

Pada banyak sistem antrean, duplikasi bukan bug yang langka; itu konsekuensi desain. Karena itu, setiap job yang punya efek samping sebaiknya memiliki idempotency key yang stabil.

Kapan idempotency key digunakan

  • Satu request API dapat dikirim ulang oleh klien atau gateway.
  • Producer timeout lalu mencoba enqueue lagi.
  • Worker selesai memproses tetapi gagal menghapus pesan.
  • DLQ direplay sebagian.

Pola implementasi

Buat key dari identitas operasi, bukan dari timestamp acak. Contoh:

idempotency_key = sha256(
  type + ":" + target_group_id + ":" + artifact_sha256 + ":" + requested_window
)

Simpan hasil eksekusi atau status klaim key tersebut di storage yang konsisten. Untuk PostgreSQL, pola yang umum adalah tabel dengan unique constraint. Untuk Redis, gunakan SET NX EX untuk klaim sementara, tetapi untuk efek samping yang kritis Anda tetap perlu catatan persisten jika hasilnya harus bisa diaudit atau direkonsiliasi.

-- PostgreSQL example
CREATE TABLE job_idempotency (
  idempotency_key text PRIMARY KEY,
  job_type text NOT NULL,
  status text NOT NULL,
  result_ref text,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

Alur sederhananya:

  1. Sebelum enqueue atau saat awal proses, coba insert idempotency key.
  2. Jika insert sukses, operasi boleh lanjut.
  3. Jika key sudah ada dan statusnya completed, jangan eksekusi ulang.
  4. Jika statusnya processing, cek timeout dan mekanisme recovery sebelum memutuskan retry.

Trade-off: idempotency menambah state dan lookup. Namun biaya ini biasanya lebih murah daripada menangani duplikasi job yang memodifikasi resource penting.

Distributed locking untuk mencegah eksekusi simultan

Idempotency mencegah duplikasi efek yang sama, tetapi belum tentu mencegah dua job berbeda memodifikasi resource yang sama secara bersamaan. Di sinilah distributed locking dibutuhkan.

Kapan lock dipakai

  • Satu host atau tenant hanya boleh dipatch oleh satu worker pada satu waktu.
  • Satu akun hanya boleh menjalankan satu rekonsiliasi saldo aktif.
  • Satu cache warm-up besar tidak boleh dijalankan paralel untuk key yang sama.

Pola lock berbasis Redis

Gunakan key lock per resource, misalnya lock:patch:host-123, dengan TTL agar tidak menggantung selamanya saat worker mati. Nilai lock sebaiknya token unik agar unlock hanya dilakukan oleh pemilik lock.

// Pseudocode Redis lock
const token = randomUUID()
const acquired = redis.set(lockKey, token, { NX: true, EX: 300 })
if (!acquired) {
  requeueWithDelay(job)
  return
}

try {
  processJob(job)
} finally {
  // unlock only if token matches
  if (redis.get(lockKey) === token) {
    redis.del(lockKey)
  }
}

Catatan penting: lock dengan TTL harus disejajarkan dengan durasi proses nyata. Jika TTL terlalu pendek, lock bisa habis saat job masih berjalan dan worker lain ikut masuk. Jika terlalu panjang, resource terlalu lama terblokir saat worker gagal.

Pola lock berbasis PostgreSQL

Jika queue dan state utama Anda berada di PostgreSQL, pendekatan yang sederhana adalah memakai row-level lock atau tabel klaim per resource dengan unique constraint. Keuntungannya adalah konsistensi lebih mudah ditalar karena lock berada dekat dengan data utama. Kekurangannya, lock yang buruk dapat meningkatkan kontensi database.

Prinsip praktis: gunakan lock pada unit resource yang memang perlu serialisasi. Lock global untuk seluruh worker hampir selalu terlalu mahal dan mengurangi throughput drastis.

Visibility timeout, ack, dan retry policy yang aman

Pada queue worker, kegagalan tidak hanya terjadi ketika kode melempar exception. Worker bisa hang, koneksi putus, host restart, atau proses dibunuh OOM killer. Karena itu Anda perlu model yang jelas tentang kapan job dianggap masih dikerjakan dan kapan boleh diambil ulang.

Visibility timeout

Visibility timeout adalah durasi saat job yang sudah diambil worker disembunyikan dari worker lain. Jika worker tidak menyelesaikan ack dalam rentang itu, job dapat muncul lagi.

Atur timeout lebih panjang dari durasi normal job, tetapi tidak terlalu panjang sehingga job yang macet baru bisa dipulihkan sangat lama. Untuk job variatif, pertimbangkan heartbeat atau mekanisme perpanjangan lease.

Klasifikasi error sebelum retry

Tidak semua error layak di-retry.

  • Transient: timeout jaringan, rate limit sementara, failover database, lock contention. Layak retry.
  • Permanent: payload invalid, artifact tidak dikenal, signature gagal, aturan bisnis menolak. Jangan retry; kirim ke DLQ atau tandai gagal permanen.
  • Suspicious: pola field aneh, lonjakan error validasi dari sumber yang sama, target di luar whitelist. Hentikan alur lebih awal dan naikkan alert.

Retry dengan backoff

Retry sebaiknya memakai exponential backoff dengan jitter, bukan interval tetap. Alasannya:

  • Mengurangi gelombang retry serempak saat dependency sedang bermasalah.
  • Mengurangi kontensi lock dan tekanan ke database/cache.
  • Memberi waktu bagi dependency untuk pulih.
// Pseudocode backoff
function nextDelay(attempt) {
  const base = Math.min(300, Math.pow(2, attempt))
  const jitter = Math.floor(Math.random() * 3)
  return base + jitter
}

Kesalahan umum: retry tanpa batas, retry untuk semua jenis error, atau retry sangat cepat pada job yang memicu side effect eksternal.

Dead-letter queue

Job yang melewati batas retry atau ditandai permanen/suspicious perlu dipindahkan ke dead-letter queue (DLQ). Tujuan DLQ bukan tempat sampah pasif, melainkan area karantina:

  • menahan payload bermasalah agar tidak terus dieksekusi,
  • menyimpan konteks untuk investigasi,
  • memungkinkan replay terkontrol setelah perbaikan.

Jangan replay DLQ secara massal tanpa klasifikasi ulang. Banyak insiden memburuk karena job dari DLQ dimasukkan kembali ke antrean utama tanpa sanitasi atau patch aplikasi yang memadai.

Isolasi worker: batasi blast radius sejak awal

Jika validasi gagal lolos dan job jahat tetap mencapai worker, lapisan terakhir yang menyelamatkan sistem adalah isolasi worker.

Batasi hak akses proses

  • Jalankan worker dengan user non-privileged.
  • Hindari akses shell kecuali benar-benar perlu.
  • Batasi filesystem: hanya direktori kerja yang diperlukan, sebaiknya read-only bila memungkinkan.
  • Batasi network egress: worker patch tidak perlu bisa mengakses seluruh internet atau seluruh subnet internal.

Pisahkan worker berdasarkan tipe job

Jangan campur job berisiko tinggi dan rendah pada pool yang sama. Contohnya:

  • worker email/notifikasi,
  • worker billing,
  • worker patch/deployment,
  • worker rekonsiliasi data.

Pemisahan ini memudahkan pembatasan secret, jalur jaringan, concurrency, dan kebijakan observability. Jika job patch memerlukan akses lebih sensitif, letakkan pada worker khusus dengan antrean sendiri, throughput terbatas, serta approval path yang lebih ketat.

Pembatasan akses cache dan queue backend

Pada Redis, hindari memberikan kredensial yang bisa membaca/menulis semua key bila worker hanya butuh namespace tertentu. Pada PostgreSQL, gunakan role terpisah untuk enqueue, dequeue, dan audit bila arsitektur memungkinkan. Tujuannya bukan sekadar least privilege secara normatif, tetapi membatasi kerusakan bila worker atau library eksekusinya disalahgunakan.

Pola yang baik: worker hanya boleh menyentuh queue key/table, idempotency store, dan tabel status yang relevan. Hindari reuse satu kredensial aplikasi untuk seluruh komponen.

Audit trail dan circuit breaker saat anomali terdeteksi

Audit trail yang benar-benar berguna

Audit trail harus cukup kaya untuk menjawab: job apa, dari mana asalnya, siapa yang memicunya, versi payload mana yang dipakai, kapan diambil worker, berapa kali retry, lock resource apa yang dipegang, dan kenapa job dipindah ke DLQ.

Minimal simpan:

  • request_id / correlation_id,
  • idempotency_key,
  • job type dan payload ringkas yang sudah disanitasi,
  • actor atau service origin,
  • attempt count dan alasan gagal,
  • worker identity dan timestamp state transition.

Hindari mencatat secret mentah atau payload sensitif penuh jika tidak diperlukan. Audit trail harus aman dibagikan untuk investigasi tanpa menimbulkan kebocoran baru.

Circuit breaker untuk anomali

Jika sistem mendeteksi lonjakan kegagalan yang tidak biasa, jangan biarkan worker terus memproses seolah-olah tidak terjadi apa-apa. Gunakan circuit breaker atau kill switch operasional untuk menghentikan sementara kategori job tertentu.

Contoh kondisi pemicu:

  • persentase error validasi job patch naik tajam,
  • banyak target di luar whitelist muncul dalam waktu singkat,
  • retry rate melonjak pada satu tipe job,
  • DLQ menerima payload dengan pola yang sama dari sumber identitas yang identik.

Saat trip, circuit breaker bisa:

  • menolak enqueue baru untuk job tertentu,
  • mengalihkan job ke antrean karantina,
  • menurunkan concurrency worker sensitif,
  • memaksa approval manual sebelum replay.
// Pseudocode circuit breaker sederhana
if (metrics.validationFailureRate("patch.apply") > threshold) {
  featureFlags.disableEnqueue("patch.apply")
  alerts.send("patch.apply disabled due to anomaly")
}

Ini efektif karena banyak insiden keamanan atau supply-chain tidak langsung terlihat sebagai eksploit sukses, melainkan sebagai pola kegagalan yang berubah tiba-tiba.

Pola implementasi untuk Redis dan PostgreSQL-based queue

Jika memakai Redis-based queue

Kelebihan: sederhana, cepat, cocok untuk throughput tinggi dan latensi rendah.

Hal yang perlu diperhatikan:

  • Gunakan namespace key yang jelas untuk queue, lock, idempotency, dan metrics.
  • Set TTL pada lock dan klaim sementara.
  • Pastikan ada mekanisme recovery untuk job yang hilang lease-nya.
  • Jangan jadikan Redis satu-satunya sumber audit permanen jika insiden perlu forensik yang kuat.

Rekomendasi pola: Redis untuk transport dan lock cepat, PostgreSQL atau storage persisten lain untuk audit trail, approval state, dan idempotency kritis.

Jika memakai PostgreSQL-based queue

Kelebihan: lebih mudah menggabungkan status job, idempotency, dan audit dalam satu sistem transaksional.

Hal yang perlu diperhatikan:

  • Hindari query polling yang terlalu agresif.
  • Gunakan model klaim job yang mencegah worker mengambil baris yang sama secara bersamaan.
  • Perhatikan kontensi pada tabel queue dan indeks yang membengkak akibat retry tinggi.

Rekomendasi pola: pisahkan tabel antrean aktif, tabel DLQ, dan tabel audit. Simpan kolom seperti available_at, attempts, last_error, dan lease_until agar alur retry dan recovery mudah dipantau.

Contoh alur insiden: payload patch mencurigakan masuk ke sistem

  1. Service internal mengirim request patch.apply untuk sekelompok host.
  2. Validasi schema lolos, tetapi validasi semantik menemukan artifact_sha256 tidak ada di daftar artefak approved.
  3. Producer menolak enqueue, mencatat audit event, dan menaikkan counter anomali.
  4. Dalam 5 menit, ada lonjakan request serupa dari origin yang sama.
  5. Circuit breaker aktif: enqueue untuk patch.apply dinonaktifkan sementara.
  6. Tim operator memeriksa audit trail dan menemukan token service yang bocor atau service upstream salah konfigurasi.
  7. Karena job ditahan sebelum masuk queue, tidak ada worker yang sempat mengambil lock host, melakukan retry, atau menyentuh target produksi.

Skenario kedua, bila satu payload lolos namun worker mendeteksi target di luar whitelist:

  1. Worker mengambil job dan memvalidasi ulang konteks target.
  2. Job ditandai permanent failure, tidak di-retry, lalu dipindah ke DLQ.
  3. Audit trail menyimpan actor, target, attempt, dan alasan penolakan.
  4. Circuit breaker menghitung pola serupa; jika melewati ambang, antrean tipe itu dikarantina.

Di sini terlihat mengapa validasi berlapis, retry policy, DLQ, dan circuit breaker harus bekerja bersama, bukan sebagai fitur terpisah.

Trade-off performa vs keamanan

Apa yang bertambah ketika sistem di-hardening

  • Lebih banyak lookup untuk validasi dan idempotency.
  • Kontensi tambahan karena lock.
  • Latency lebih tinggi untuk enqueue/proses pada job sensitif.
  • Operasional lebih kompleks: DLQ, audit, approval, circuit breaker.

Kenapa trade-off ini sering layak

Untuk job berisiko tinggi seperti patch, billing, mutasi data penting, atau provisioning akses, throughput maksimum bukan prioritas utama. Yang lebih penting adalah mencegah eksekusi salah satu kali saja yang berdampak besar. Sebaliknya, untuk job rendah risiko seperti notifikasi non-kritis, Anda bisa memakai aturan yang lebih longgar.

Pendekatan praktis adalah risk-tiered queue:

  • Tingkat rendah: validasi dasar, retry terbatas, observability standar.
  • Tingkat menengah: idempotency wajib, DLQ, lock per resource.
  • Tingkat tinggi: approval artefak, isolasi worker khusus, audit trail rinci, circuit breaker ketat, akses jaringan terbatas.

Checklist mitigasi hardening queue worker

  • Payload divalidasi sebelum enqueue dan divalidasi ulang di worker.
  • Gunakan kontrak payload sempit; hindari command/script mentah.
  • Terapkan idempotency key untuk semua job yang punya efek samping.
  • Pasang distributed lock per resource yang butuh serialisasi.
  • Atur visibility timeout sesuai durasi nyata, dengan lease extension bila perlu.
  • Klasifikasikan error: transient, permanent, suspicious.
  • Retry memakai exponential backoff + jitter, bukan loop cepat.
  • Gunakan dead-letter queue sebagai karantina, bukan sekadar tempat buang error.
  • Pisahkan worker berdasarkan tingkat risiko dan kebutuhan akses.
  • Batasi kredensial Redis/PostgreSQL dan namespace yang boleh diakses.
  • Simpan audit trail dengan correlation ID, actor, attempt, dan alasan gagal.
  • Siapkan circuit breaker atau kill switch untuk menahan job saat anomali terdeteksi.
  • Uji replay DLQ, lock expiry, crash recovery, dan duplicate delivery secara berkala.

Penutup

Hardening queue worker dari patch jahat pada dasarnya adalah latihan disiplin arsitektur: jangan percaya payload hanya karena datang dari jalur internal, jangan menganggap retry selalu aman, dan jangan memberi worker akses lebih luas dari yang dibutuhkan. Pada sistem terdistribusi, keamanan queue jarang bergantung pada satu mekanisme. Ia lahir dari kombinasi validasi awal, idempotency, locking, timeout yang sehat, retry terkontrol, DLQ, isolasi proses, dan kontrol operasional seperti audit trail serta circuit breaker.

Jika Anda harus memilih prioritas implementasi, mulailah dari empat hal yang paling sering menyelamatkan produksi: validasi semantik sebelum enqueue, idempotency key, retry policy yang benar, dan isolasi worker sensitif. Setelah itu, tambahkan lock per resource, DLQ, dan circuit breaker untuk memperkecil dampak saat sesuatu yang tak tervalidasi tetap lolos.