Bali, Indonesia
Insights

Pengerasan Parameter access_by_lua dan Restriksi Cosocket OpenResty untuk Mencegah Kebocoran Header serta Penetrasi SSRF pada Reverse Proxy PHP-FPM

October 15, 2026
0:00
Integrasi OpenResty ke dalam arsitektur reverse proxy Nginx menambahkan kapabilitas komputasi terdistribusi melalui Lua coroutine yang berjalan di atas loop I/O berbasis kejadian (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.
Nginx memproses HTTP request melalui fase berurutan: 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 NginxDampak Pemrosesan FilterRisiko Keamanan & Operasional
ngx.OKMenghentikan 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.DECLINEDMenyerahkan 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.
Masalah mendasar terjadi ketika 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.
Model konkurensi Nginx worker bertumpu pada arsitektur single-threaded non-blocking event loop. Satu worker process menangani ribuan koneksi konkuren menggunakan epoll (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 tersebut
Cosocket (ngx.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:
  1. Socket disetel ke mode non-blocking (O_NONBLOCK) menggunakan fcntl.
  2. Fungsi IO kernel dipanggil. Jika operasi menghasilkan error EAGAIN atau EINPROGRESS, OpenResty menangguhkan (yield) coroutine Lua yang sedang berjalan menggunakan lua_yield().
  3. Descriptor socket didaftarkan ke epoll instance milik Nginx worker via ngx_add_event.
  4. Thread worker bebas memproses request lain yang siap pada antrean epoll.
  5. Ketika kernel membangkitkan notifikasi kesiapan read/write pada descriptor socket tersebut via epoll_wait(), OpenResty memulihkan (resume) coroutine Lua melalui lua_resume() dengan konteks eksekusi yang utuh.
Penggunaan blocking I/O di dalam handler akses merusak seluruh model throughput web server. Pemeriksaan latensi dapat dilakukan dengan 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]);
}'
Jika histogram mendeteksi latensi di atas rentang ratusan mikrodetik pada thread worker Nginx, modul sedang mengeksekusi blocking I/O yang menahan antrean koneksi lain pada socket backlog kernel.
Dalam sistem reverse proxy yang memproteksi backend PHP-FPM, OpenResty sering digunakan untuk melakukan pra-validasi token ke identity provider internal, memverifikasi webhook URL, atau menarik metadata dari microservice lain. Pola implementasi cosocket yang rentan terhadap Server-Side Request Forgery (SSRF) umumnya berakar pada ketergantungan terhadap DNS resolver eksternal tanpa validasi IP hasil resolusi terhadap rentang privat internal (RFC 1918, link-local, loopback, dan cloud metadata endpoint).
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 response
Kerentanan ini diperparah oleh fenomena DNS Rebinding. Jika validasi domain hanya dilakukan di awal menggunakan fungsi resolusi non-sinkron, kemudian koneksi cosocket kedua mengeksekusi request ke hostname yang sama, penyerang dapat mengembalikan respons DNS A record pertama dengan IP publik yang lolos validasi, dan respons DNS A record kedua dengan TTL 0 bernilai 127.0.0.1 atau 10.0.0.0/8. Untuk mengamankan cosocket dari SSRF secara mekanis, sistem harus menerapkan:
  1. Resolusi DNS internal eksplisit dengan batas waktu (resolver timeout).
  2. Pengecekan daftar hitam CIDR pada level IP numerik biner (bukan validasi string URL).
  3. 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).
Satu Nginx worker process memelihara satu instance Lua VM (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 _M
Ketika coroutine melakukan operasi I/O asinkron (seperti sock: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.ctx yang 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 luacheck dengan flag --no-global dalam pipeline CI/CD untuk memastikan tabel _G tidak dimutasi pada runtime.
Arsitektur pertahanan di bawah mengimplementasikan isolasi state, pengecekan CIDR IP internal secara deterministik menggunakan operasi bitwise, penanganan keepalive pool cosocket yang aman, dan isolasi header internal sebelum mencapai pool PHP-FPM. Dalam infrastruktur hosting multi-tenant, beban alokasi memori worker PHP-FPM itu sendiri perlu dibatasi secara ketat di level kernel menggunakan cgroups v2, sebagaimana diulas dalam Penerapan cgroups v2 memory.high dan systemd Slice untuk Isolasi Pool PHP-FPM Multi-Tenant.
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 _M
Konfigurasi ini memastikan fase akses memutus request secara deterministik, membersihkan header sebelum menuju pool upstream FastCGI, dan mencegah eksekusi ganda jika request dibatalkan.
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
    }
}
Jika worker PHP-FPM di belakang reverse proxy mengalami saturasi memory karena request yang lolos, parameter 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.
Kegagalan penanganan cosocket dapat diidentifikasi secara langsung pada level transport layer kernel Linux. Ketika perintah 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).
Periksa distribusi state socket antara worker Nginx dan authentication microservice:
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 -l
Nilai CLOSE_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'
Ketika kode Lua pada 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")
}
Buka log trace untuk memverifikasi apakah terjadi abort kompilasi pada fase akses:
BASH
tail -f /var/log/nginx/luajit_trace.log
Format keluaran yang menunjukkan kegagalan JIT trace compiler:
TEXT
[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]
Identifikasi NYI menunjukkan bagian kode yang harus dioptimasi (misalnya mengganti konkatenasi string dinamis dengan array table join) agar tidak membebani event loop worker.
Untuk memastikan operasi cosocket sepenuhnya non-blocking, hubungkan 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 128
Alur eksekusi cosocket yang valid harus mengikuti urutan berikut:
TEXT
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) = 78
Jika jejak menunjukkan connect() 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.