epoll). Namun, intervensi siklus hidup HTTP request Nginx pada fase access_by_lua memunculkan anomali state machine jika pengembang tidak memahami batas antara pemrosesan permintaan (request-processing phases) dan pemrosesan respons (response-handling/filter phases). Kegagalan mitigasi pada titik ini dapat menyebabkan kebocoran header otentikasi internal dan pembukaan celah Server-Side Request Forgery (SSRF) melalui implementasi cosocket yang tidak diisolasi sebelum request dialirkan ke pool FastCGI PHP-FPM.
Siklus Fase Nginx dan Anomali Kontrol Terminasi ngx.exit()
NGX_HTTP_POST_READ_PHASE, NGX_HTTP_SERVER_REWRITE_PHASE, NGX_HTTP_FIND_CONFIG_PHASE, NGX_HTTP_REWRITE_PHASE, NGX_HTTP_POST_REWRITE_PHASE, NGX_HTTP_PREACCESS_PHASE, NGX_HTTP_ACCESS_PHASE, NGX_HTTP_POST_ACCESS_PHASE, NGX_HTTP_PRECONTENT_PHASE, NGX_HTTP_CONTENT_PHASE, serta fase penyaringan keluaran (header_filter dan body_filter).
Modul ngx_http_lua_module mengikat handler Lua ke dalam fase-fase tersebut melalui direktif spesifik. Direktif access_by_lua_block dieksekusi pada NGX_HTTP_ACCESS_PHASE. Pada fase ini, logika kontrol akses sering kali menggunakan fungsi primitif ngx.exit(status) untuk memutus aliran request yang tidak terotorisasi.
Parameter Status ngx.exit() | Perilaku State Machine Nginx | Dampak Pemrosesan Filter | Risiko Keamanan & Operasional |
|---|---|---|---|
ngx.OK | Menghentikan eksekusi Lua saat ini; melanjutkan fase Nginx berikutnya (content). | Header filter downstream tetap dieksekusi secara normal. | Melewatkan pemeriksaan access berikutnya jika terdapat multiple access handler. |
ngx.DECLINED | Menyerahkan kontrol kembali ke fase Nginx; mengevaluasi handler akses C berikutnya. | Tidak memicu pengiriman respons mandiri. | Bypass tak disengaja jika Nginx core access module tidak dikonfigurasi dengan flag satisfy all. |
ngx.HTTP_OK (200) | Memotong fase konten; memicu perakitan respons HTTP 200 mandiri. | Fase header_filter dan body_filter tetap dieksekusi. | Header internal yang disiapkan untuk upstream dapat bocor ke client. |
ngx.HTTP_FORBIDDEN (403) | Memotong fase konten; memicu pembangkitan respons error internal. | Memanggil ngx_http_special_response_handler dan filter chain. | Potensi paparan halaman error standar atau eksekusi error page upstream yang salah konfigurasi. |
ngx.ERROR (-1) | Menghentikan koneksi secara mendadak pada level socket protocol. | Mengabaikan seluruh filter chain; menutup deskriptor socket. | Reset TCP mendadak (RST), klien menerima koneksi putus tanpa payload HTTP valid. |
ngx.exit(status) dipanggil dengan kode status HTTP (>= 200). Nginx memperlakukan pemanggilan ini sebagai penghentian fase penanganan request, tetapi tidak menghentikan fase pemrosesan respons. Nginx langsung mengalihkan eksekusi ke rantai filter header (header_filter) dan body (body_filter).
Jika aplikasi mengatur header internal untuk diteruskan ke backend FastCGI (misalnya menambahkan ngx.req.set_header("X-User-Internal-Roles", roles) di awal fase akses), dan kemudian logika otentikasi memanggil ngx.exit(ngx.HTTP_UNAUTHORIZED) tanpa membersihkan header tersebut, header yang dimutasi tidak terkirim ke backend. Namun, jika ada logika di header_filter_by_lua yang membaca state request untuk menyalin header permintaan ke dalam header respons, token internal tersebut dapat terekspos langsung kepada klien eksternal.
Perbedaan Mekanisme Blokir: Cosocket vs Lua I/O Standar
EPOLLIN, EPOLLOUT, EPOLLET).
Ketika pustaka standar Lua seperti io.open(), os.execute(), atau socket berbasis C murni non-cosocket (seperti luasocket standar) dipanggil di dalam worker, eksekusi kode beralih ke blocking system call. Thread worker Nginx akan terjebak dalam syscall connect(), read(), atau write() tanpa flag O_NONBLOCK.
TEXT
Thread Worker Nginx (PID: 12044)
-> Menerima request A via epoll_wait()
-> Menjalankan access_by_lua
-> Memanggil blocking luasocket:connect("10.0.0.5", 6379)
-> Kernel context switch ke sleep state (TASK_INTERRUPTIBLE)
-> epoll_wait() TERHENTI untuk koneksi B, C, D, ...
-> Latensi melonjak secara global pada worker tersebutngx.socket.tcp()) mengabstraksi I/O jaringan dengan mengaitkan deskriptor file socket ke dalam event loop Nginx core. Ketika operasi jaringan seperti tcpsock:connect() atau tcpsock:receive() dieksekusi:
- Socket disetel ke mode non-blocking (
O_NONBLOCK) menggunakanfcntl. - Fungsi IO kernel dipanggil. Jika operasi menghasilkan error
EAGAINatauEINPROGRESS, OpenResty menangguhkan (yield) coroutine Lua yang sedang berjalan menggunakanlua_yield(). - Descriptor socket didaftarkan ke epoll instance milik Nginx worker via
ngx_add_event. - Thread worker bebas memproses request lain yang siap pada antrean epoll.
- Ketika kernel membangkitkan notifikasi kesiapan read/write pada descriptor socket tersebut via
epoll_wait(), OpenResty memulihkan (resume) coroutine Lua melaluilua_resume()dengan konteks eksekusi yang utuh.
bpftrace untuk mengukur durasi worker thread tertahan di luar event loop Nginx.
BASH
# Melacak latency blocking read pada worker process Nginx
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_read /comm == "nginx"/ {
@start[tid] = nsecs;
}
tracepoint:syscalls:sys_exit_read /comm == "nginx" && @start[tid]/ {
$dur = (nsecs - @start[tid]) / 1000;
@read_latency_us = hist($dur);
delete(@start[tid]);
}'Analisis Celah SSRF pada Cosocket dan Resolusi DNS
TEXT
Klien Penyerang
-> HTTP Request dengan Target Webhook URL: http://169.254.169.254/latest/meta-data/
-> Nginx Worker (access_by_lua)
-> Cosocket mengeksekusi connect ke 169.254.169.254:80
-> Membaca kredensial IAM Instance Profile
-> Response bocor ke log atau dipantulkan ke client response127.0.0.1 atau 10.0.0.0/8.
Untuk mengamankan cosocket dari SSRF secara mekanis, sistem harus menerapkan:
- Resolusi DNS internal eksplisit dengan batas waktu (resolver timeout).
- Pengecekan daftar hitam CIDR pada level IP numerik biner (bukan validasi string URL).
- Penggunaan langsung IP hasil resolusi pada panggilan
tcpsock:connect(), sehingga melewati proses resolusi ganda yang rentan terhadap time-of-check to time-of-use (TOCTOU).
Perangkap Global State pada LuaJIT Virtual Machine
lua_State). Seluruh request yang dialokasikan oleh kernel ke worker tersebut mengeksekusi coroutine di dalam VM yang sama. Variabel yang dideklarasikan secara global (tanpa sintaks local) atau disimpan pada level modul (top-level table) akan dialokasikan pada tabel global _G LuaJIT.
LUA
-- CONTOH KODE RUSAK (LEAKING STATE & RACE CONDITION)
local _M = {}
-- Global modul level table (terbagi ke seluruh request dalam 1 worker)
_M.client_auth_context = {}
function _M.authenticate(token)
-- Mutasi state bersamaan antar-request yang overlapping
_M.client_auth_context.token = token
local sock = ngx.socket.tcp()
sock:connect("127.0.0.1", 6379)
-- Titik YIELD: Request lain masuk ke worker yang sama
-- dan menimpa _M.client_auth_context.token sebelum resume!
local res = sock:receive("*l")
return _M.client_auth_context.token == res
end
return _Msock:connect), coroutine melepaskan CPU. Selama penangguhan tersebut, Nginx memproses request baru pada worker yang sama, memanggil fungsi authenticate(), dan menimpa _M.client_auth_context.token. Saat request pertama dipulihkan kembali oleh epoll, nilai konteks telah terkontaminasi oleh request kedua.
Untuk mencegah kontaminasi state:
- Konteks request wajib diisolasi menggunakan tabel bawaan
ngx.ctxyang memiliki siklus hidup eksklusif per request HTTP. - Modul Lua harus dibuat stateless atau read-only setelah inisialisasi pada fase
init_by_lua_block. - Gunakan linter seperti
luacheckdengan flag--no-globaldalam pipeline CI/CD untuk memastikan tabel_Gtidak dimutasi pada runtime.
Implementasi Restriksi Egress Cosocket dan Filtrasi Header
Modul Restriksi Jaringan Cosocket (/etc/nginx/lua/security_network.lua)
LUA
local bit = require("bit")
local band = bit.band
local rshift = bit.rshift
local tonumber = tonumber
local ipairs = ipairs
local type = type
local _M = {}
-- Konversi string IPv4 ke integer 32-bit big-endian
local function ipv4_to_uint32(ip_str)
if type(ip_str) ~= "string" then return nil end
local o1, o2, o3, o4 = ip_str:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
o1, o2, o3, o4 = tonumber(o1), tonumber(o2), tonumber(o3), tonumber(o4)
if o1 and o2 and o3 and o4 and
o1 <= 255 and o2 <= 255 and o3 <= 255 and o4 <= 255 then
return o1 * 16777216 + o2 * 65536 + o3 * 256 + o4
end
return nil
end
-- Tabel rentang terlarang: Loopback, RFC1918, Link-Local, Cloud Metadata
local BLOCKED_RANGES = {
{ base = ipv4_to_uint32("0.0.0.0"), mask = 4278190080 }, -- 0.0.0.0/8
{ base = ipv4_to_uint32("10.0.0.0"), mask = 4278190080 }, -- 10.0.0.0/8
{ base = ipv4_to_uint32("127.0.0.0"), mask = 4278190080 }, -- 127.0.0.0/8
{ base = ipv4_to_uint32("169.254.0.0"), mask = 4294901760 }, -- 169.254.0.0/16
{ base = ipv4_to_uint32("172.16.0.0"), mask = 4293918720 }, -- 172.16.0.0/12
{ base = ipv4_to_uint32("192.168.0.0"), mask = 4294901760 }, -- 192.168.0.0/16
{ base = ipv4_to_uint32("224.0.0.0"), mask = 3758096384 }, -- Multicast /24
}
function _M.is_egress_allowed(ip_str)
local ip_int = ipv4_to_uint32(ip_str)
if not ip_int then
return false, "INVALID_IP_FORMAT"
end
for _, range in ipairs(BLOCKED_RANGES) do
if band(ip_int, range.mask) == range.base then
return false, "TARGET_IN_RESTRICTED_SUBNET"
end
end
return true, nil
end
-- Safe cosocket wrapper untuk HTTP auth outbound
function _M.safe_http_get(target_ip, port, host_header, path, timeout_ms)
local allowed, err = _M.is_egress_allowed(target_ip)
if not allowed then
return nil, err
end
local sock = ngx.socket.tcp()
sock:settimeout(timeout_ms or 2000)
-- Menghubungkan langsung ke IP numerik untuk mencegah DNS Rebinding
local ok, conn_err = sock:connect(target_ip, port)
if not ok then
return nil, "CONNECT_FAILED: " .. conn_err
end
local req = string.format(
"GET %s HTTP/1.1\r
Host: %s\r
Connection: keep-alive\r
Accept: application/json\r
\r
",
path, host_header
)
local bytes, send_err = sock:send(req)
if not bytes then
sock:close()
return nil, "SEND_FAILED: " .. send_err
end
local status_line, read_err = sock:receive("*l")
if not status_line then
sock:close()
return nil, "READ_FAILED: " .. read_err
end
-- Kembalikan socket ke worker connection pool
-- Parameter: timeout (ms), max_idle_connections per worker
sock:setkeepalive(60000, 50)
return status_line, nil
end
return _MKonfigurasi Nginx Reverse Proxy ke PHP-FPM (/etc/nginx/conf.d/hardened_app.conf)
NGINX
# Resolver lokal yang di-cache di level worker Nginx
resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
resolver_timeout 2s;
upstream php_fpm_backend {
server unix:/run/php/php8.2-fpm.sock max_fails=3 fail_timeout=10s;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name api.infra.internal;
ssl_certificate /etc/ssl/certs/api.crt;
ssl_certificate_key /etc/ssl/private/api.key;
ssl_protocols TLSv1.2 TLSv1.3;
# Membersihkan state header upstream secara proaktif dari client input
# Menghindari injection header internal via external client manipulation
fastcgi_hide_header X-Internal-Caller;
fastcgi_hide_header X-Tenant-Identity;
location / {
# Inisialisasi isolasi state context
set $tenant_uid "";
set $auth_failure_reason "";
access_by_lua_block {
local net_sec = require("security_network")
-- Isolasi konteks request
local ctx = ngx.ctx
ctx.auth_processed = false
local client_token = ngx.req.get_headers()["X-Client-Token"]
if not client_token or #client_token ~= 64 then
ngx.var.auth_failure_reason = "MISSING_OR_MALFORMED_TOKEN"
ngx.status = ngx.HTTP_UNAUTHORIZED
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error": "Unauthorized: Invalid Token Architecture"}')
-- Terminasi tanpa merusak error flow Nginx
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
-- Contoh resolusi target upstream auth: IP numerik hasil resolver internal
local auth_server_ip = "198.51.100.25"
local status, err = net_sec.safe_http_get(
auth_server_ip,
8080,
"auth.service.local",
"/validate?t=" .. client_token,
1500
)
if not status or not status:find("200 OK") then
ngx.log(ngx.ERR, "Authentication failed or cosocket blocked: ", err)
ngx.var.auth_failure_reason = err or "AUTH_FAILED"
ngx.status = ngx.HTTP_FORBIDDEN
ngx.header["Content-Type"] = "application/json"
ngx.say('{"error": "Forbidden: Upstream Validation Failed"}')
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- Simpan state ke variabel yang aman untuk fastcgi_param
ngx.var.tenant_uid = "tenant_" .. string.sub(client_token, 1, 16)
ctx.auth_processed = true
}
# Mencegah eksekusi upstream jika access phase mengalami abort
try_files $uri @fastcgi_pool;
}
location @fastcgi_pool {
include fastcgi_params;
fastcgi_pass php_fpm_backend;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
# Injeksi parameter yang telah divalidasi ketat oleh Lua
fastcgi_param X_TENANT_UID $tenant_uid;
fastcgi_param X_AUTH_STATUS "VERIFIED";
# Mitigasi race condition pada PHP-FPM socket
fastcgi_connect_timeout 3s;
fastcgi_send_timeout 10s;
fastcgi_read_timeout 10s;
# Isolasi buffer FastCGI untuk mencegah worker stall
fastcgi_buffering on;
fastcgi_buffer_size 16k;
fastcgi_buffers 16 16k;
fastcgi_busy_buffers_size 32k;
}
# Menutup filter header agar tidak membocorkan stack Lua
header_filter_by_lua_block {
ngx.header["Server"] = nil
ngx.header["X-Powered-By"] = nil
if ngx.status >= 400 then
ngx.header["X-Auth-Debug"] = ngx.var.auth_failure_reason
end
}
}memory.high kernel cgroups v2 harus digunakan untuk menahan alokasi tanpa langsung memicu proses OOM killer mendadak, sesuai prinsip di artikel Mencegah OOM Cascading di Shared Hosting: Konfigurasi Cgroups v2 memory.high untuk Menahan Ledakan Worker PHP-FPM.
Verifikasi Socket State dan Observabilitas Sistem
sock:setkeepalive() diabaikan atau gagal dipanggil karena exception runtime yang tidak tertangkap oleh pcall(), socket cosocket akan beralih status ke CLOSE_WAIT (jika remote peer menutup koneksi terlebih dahulu) atau TIME_WAIT (jika OpenResty menutup koneksi secara acak).
Diagnosis Status Socket Pool Menggunakan ss
BASH
# Memeriksa jumlah socket Nginx yang tertahan pada status CLOSE_WAIT dan TIME_WAIT
ss -tan state close-wait '( sport = :http or sport = :https or dport = :8080 )' | wc -l
ss -tan state time-wait '( dport = :8080 )' | wc -lCLOSE_WAIT yang tinggi menandakan remote server telah mengirimkan segmen FIN, tetapi worker OpenResty tidak pernah memanggil sock:close() atau sock:setkeepalive(), menyebabkan file descriptor bocor di dalam Nginx worker process.
Hal ini dapat diverifikasi dengan memeriksa batasan file descriptor per worker:
BASH
# Memeriksa file descriptor usage pada PID Nginx worker
ls -l /proc/$(pgrep -f 'nginx: worker process' | head -n 1)/fd | wc -l
cat /proc/$(pgrep -f 'nginx: worker process' | head -n 1)/limits | grep 'Max open files'Profiling Trace LuaJIT dan Bailout Event
access_by_lua memanggil fungsi yang tidak dapat di-compile oleh JIT compiler (Not Yet Implemented / NYI) ke dalam kode mesin native, LuaJIT jatuh kembali (fallback) ke mode interpreter. Pemanggilan I/O tertentu atau manipulasi tabel global memaksa CPU beralih konteks berkali-kali.
Eksekusi profiling trace dump dapat dipantau langsung menggunakan skrip jit.dump atau jit.v bawaan OpenResty:
NGINX
# Tambahkan pada init_by_lua_block untuk debugging trace non-kompilasi
init_by_lua_block {
local v = require("jit.v")
v.start("/var/log/nginx/luajit_trace.log")
}BASH
tail -f /var/log/nginx/luajit_trace.logTEXT
[TRACE 1 /etc/nginx/lua/security_network.lua:28 -- NYI: FastFunc string.format at /etc/nginx/lua/security_network.lua:47]
[TRACE 2 (1/0) /etc/nginx/lua/security_network.lua:15 -- stitch]NYI menunjukkan bagian kode yang harus dioptimasi (misalnya mengganti konkatenasi string dinamis dengan array table join) agar tidak membebani event loop worker.
Audit Tracing Syscall Cosocket dengan strace
strace ke worker process Nginx saat menangani simulasi beban:
BASH
sudo strace -f -p $(pgrep -f 'nginx: worker process' | head -n 1) \
-e trace=network,epoll_pwait,fcntl -s 128TEXT
socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 18
fcntl(18, F_GETFL) = 0x2 (flags O_RDWR)
fcntl(18, F_SETFL, O_RDWR|O_NONBLOCK) = 0
connect(18, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("198.51.100.25")}, 16) = -1 EINPROGRESS
epoll_ctl(9, EPOLL_CTL_ADD, 18, {EPOLLIN|EPOLLOUT|EPOLLET, {u32=..., u64=...}}) = 0
epoll_pwait(9, [{events=EPOLLOUT, data={u32=..., u64=...}}], 512, 1500, NULL, 8) = 1
sendto(18, "GET /validate?t=...", 78, 0, NULL, 0) = 78connect() mengembalikan status 0 secara sinkron tanpa flag O_NONBLOCK dan tanpa pendaftaran ke deskriptor epoll EPOLL_CTL_ADD, socket tersebut diinisialisasi oleh pustaka C eksternal atau Lua standard socket yang mematikan kapabilitas asinkron Nginx worker. Perbaiki implementasi dengan membatasi eksekusi network I/O hanya melalui API cosocket ngx.socket.* dan terapkan sanitasi ketat sebelum koneksi dialihkan ke pool PHP-FPM.