Laravel หลัง proxy เห็น IP เป็น 127.0.0.1 — ฟอร์มเงียบ ไม่มี error
บั๊กที่หายากที่สุดคือบั๊กที่ทุกชั้นบอกว่าปกติ — access log ตอบ 302 สวยงาม mail log ว่างเปล่า app log ไม่มีคำเตือน ทั้งที่ผู้ใช้กดส่งฟอร์มแล้วไม่มีอะไรเกิดขึ้นเลย
สรุปใน 1 นาที
- อาการ/ปัญหา
- ฟอร์มติดต่อกดส่งแล้วขึ้นว่าไม่ใช่คน เมลไม่ออก แต่ log ทุกชั้นเงียบ
- ใครควรอ่าน
- คนที่รัน Laravel หลัง nginx-to-apache บนโฮสต์ DirectAdmin
- ทางแก้แบบสั้น
- $request->ip() เห็นเป็น 127.0.0.1 ด่านตรวจบอทเลยไม่ผ่าน ให้ตั้ง TrustProxies อ่าน IP จริง และห้ามใส่ '*'
พอดีมีคนแจ้งเข้ามาว่าฟอร์มติดต่อบนเว็บ Laravel ตัวหนึ่งกดส่งแล้วเด้งข้อความว่ายืนยันไม่ผ่าน ให้ลองใหม่ ผมลองเองก็เจอบ้างไม่เจอบ้าง แต่ที่แน่ ๆ คือเมลไม่เคยออกเลยสักฉบับ ตอนแรกคิดว่าเป็นเรื่อง SMTP เพราะเว็บนี้อยู่บน DirectAdmin ที่เพิ่งย้ายเครื่องมา แต่ไล่ไปไล่มาปรากฏว่าไม่เกี่ยวกับเมลเลยแม้แต่นิดเดียว ต้นเหตุคือแอปมองเห็นเลข IP ของคนที่เข้ามาเป็น 127.0.0.1 ทุกคน
เคสนี้ผมว่าน่าเล่าเพราะมันสอนสองเรื่องพร้อมกัน — เรื่องแรกคือวิธีไล่บั๊กที่ทุก log บอกว่าปกติ เรื่องที่สองคือกับดักตอนแก้ ที่หลายคนแก้แล้วเปิดช่องโหว่ทิ้งไว้โดยไม่รู้ตัว
โดเมนกับเลข IP ในบทความเป็นค่าตัวอย่างทั้งหมด
เส้นทางของ request บนโฮสต์แบบนี้มีกี่ชั้น
ก่อนอื่นต้องเห็นภาพก่อนว่ากว่า request จะถึงโค้ดเรา มันผ่านมือใครมาบ้าง บน DirectAdmin ที่ตั้ง web stack แบบ nginx คุมหน้า apache อยู่หลัง แล้วยังมี CDN คั่นอีกชั้น หน้าตาเป็นแบบนี้
ชั้นสุดท้ายคือหัวใจของเรื่องครับ PHP มองเห็นแค่คนที่ต่อเข้ามาหาตัวเองโดยตรง ซึ่งก็คือ proxy ในเครื่องเดียวกัน เลข IP ของคนจริง ๆ ถูกส่งต่อมาในรูป header (X-Forwarded-For, CF-Connecting-IP) ไม่ใช่ใน REMOTE_ADDR และ Laravel 11/12 จะไม่แตะ header พวกนั้นเลยจนกว่าจะตั้ง TrustProxies ซึ่งเป็นค่าเริ่มต้นที่ถูกต้องแล้ว เพราะ header ปลอมได้ กรอบคิดคือ framework จะไม่เชื่อจนกว่าคนดูแลจะบอกว่าเชื่อใครได้บ้าง
ปัญหาคือคนเขียนแอปส่วนใหญ่ไม่รู้ว่าตัวเองต้องบอก แล้ว $request->ip() ก็ไม่เคยโยน exception มันคืน 127.0.0.1 หน้าตาเฉยเหมือนเป็นคำตอบที่ถูก
ลำดับวินิจฉัย: ไล่จากนอกเข้าใน แล้วเจอความเงียบ
สิ่งที่ทำให้เคสนี้กินเวลา คือทุกจุดที่ควรจะร้องกลับไม่ร้อง ผมไล่ตามลำดับนี้
- access log ของโดเมน — เห็น POST /contact ตอบ 302 แปลว่า request เข้าถึงแอปแล้วจริง และแอปตอบ redirect กลับไปหน้าเดิม (ก็คือ path ที่มี error ไม่ใช่ path ที่สำเร็จ) ตัดเรื่อง WAF และ 404 ทิ้งได้
- mail log ของเซิร์ฟเวอร์ — ไม่มีร่องรอยการส่งเลยแม้แต่บรรทัดเดียวในช่วงเวลานั้น ตัดเรื่อง SMTP auth และ relay ทิ้ง เพราะถ้าส่งแล้วโดนปฏิเสธ มันต้องมีบรรทัด
- laravel.log — ไม่มี exception ไม่มี warning ที่ผมดักไว้รอบ Mail::send ทั้งที่ใน controller มี try/catch เขียน log ไว้แล้ว
- สรุปจากสามข้อบน — แอปทำงานถึงจุดหนึ่งแล้ว return ออกไปก่อนจะถึงบรรทัดส่งเมล ที่เหลือคือหาว่าจุดนั้นอยู่ตรงไหน
# 1) request ถึงแอปไหม และแอปตอบอะไร
grep 'POST /contact' ~/domains/app.example.com/logs/*access.log | tail -5
# 203.0.113.45 - - [11/Aug/2026:10:22:41] "POST /contact HTTP/2.0" 302 ...
# 2) มีการส่งเมลจริงไหมในช่วงเวลานั้น
sudo grep '10:22' /var/log/exim/mainlog | grep -i contact
# (ว่างเปล่า)
# 3) แอปบ่นอะไรไว้ไหม
tail -50 ~/domains/app.example.com/public_html/storage/logs/laravel.log
# (ไม่มีอะไรในช่วงเวลานั้น)
พอมาถึงข้อ 4 ผมก็เปิด controller อ่านตามลำดับบรรทัด แล้วก็เจอด่านตรวจบอทที่ขวางอยู่ก่อนหน้า Mail::send — โค้ดหน้าตาประมาณนี้
// app/Http/Controllers/ContactController.php (ก่อนแก้)
$verify = Http::asForm()->post('https://challenges.example/siteverify', [
'secret' => config('services.captcha.secret'),
'response' => $request->input('cf-turnstile-response'),
'remoteip' => $request->ip(), // <- ตัวปัญหาอยู่บรรทัดนี้
]);
if (! ($verify->json('success') ?? false)) {
return back()->withErrors(['captcha' => 'ยืนยันไม่ผ่าน กรุณาลองใหม่']);
}
Mail::to(config('mail.contact_to'))->send(new ContactMessage($data)); // ไม่เคยมาถึง
เจ้าของบริการ captcha รับ remoteip ไว้เทียบกับ IP ที่ตอบ challenge ตอนแรก ฝั่งนั้นเห็นคนกดจริงมาจาก 203.0.113.45 แต่เซิร์ฟเวอร์เราแจ้งไปว่า 127.0.0.1 มันเลยตอบ success:false กลับมา แล้ว controller ก็ return ตรงนั้น จบเห่ — ไม่ผิดอะไรสักคนเดียว ทุกคนทำตามหน้าที่ครบถ้วน แต่ผลรวมคือฟอร์มใช้ไม่ได้
ยืนยันข้อสงสัยด้วย route ชั่วคราวสั้น ๆ อันเดียวก็พอ
Route::get('/whoami', fn (Request $r) => [
'ip' => $r->ip(),
'ips' => $r->ips(),
'remote' => $r->server('REMOTE_ADDR'),
'xff' => $r->header('X-Forwarded-For'),
]);
{"ip":"127.0.0.1","ips":["127.0.0.1"],
"remote":"127.0.0.1","xff":"203.0.113.45, 198.51.100.7"}
header มีข้อมูลครบมาตลอด แอปแค่ไม่ยอมอ่านเท่านั้นเอง
แก้ทางไหนดี — ตัดออก หรือทำให้อ่านถูก
ตัด remoteip ออกจากคำขอ
พารามิเตอร์นี้เป็น optional ส่งแค่ token ก็ตรวจผ่านปกติ แก้บรรทัดเดียว ฟอร์มกลับมาใช้ได้ทันที เหมาะเมื่อแอปไม่ได้ต้องใช้ IP จริงที่อื่นอีก
ตั้ง TrustProxies ให้เชื่อ proxy ในเครื่อง
แอปได้ IP จริงกลับมาใช้ทั้งระบบ — throttle, log, ban, audit trail ถูกต้องหมด แต่ต้องระบุให้แคบและเข้าใจว่ากำลังเชื่ออะไรอยู่
รอบนั้นผมเลือกทำทั้งสองอย่างครับ ตัด remoteip ออกก่อนเพื่อให้ฟอร์มใช้ได้ในนาทีนั้น แล้วค่อยตั้ง TrustProxies ตามหลัง เพราะแอปตัวนี้มี rate limit ที่คิดตาม IP ด้วย ซึ่งตอนนั้นมันยุบรวมทุกคนบนโลกเป็นถังเดียวชื่อ 127.0.0.1 อยู่ — แปลว่าคนใช้งานปกติสิบคนพร้อมกันก็โดนกันเองแล้ว และถ้าจะแบนใครสักคนก็เท่ากับแบนทั้งเว็บ
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
// เชื่อเฉพาะ proxy ที่อยู่ในเครื่องเดียวกันเท่านั้น
$middleware->trustProxies(
at: ['127.0.0.1', '::1'],
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO,
);
})
ทำไม '*' ถึงห้ามใช้ ทั้งที่มันทำให้ผ่านเหมือนกัน
คำแนะนำที่เจอเกลื่อนเน็ตคือใส่ at: '*' แล้วจบ ๆ ไป ซึ่งใช้ได้จริง — และเปิดช่องให้คนปลอม IP ตัวเองได้จริงเหมือนกัน
กลไกอยู่ที่ nginx ครับ ปกติเราจะตั้งส่งต่อ header แบบนี้
# ฝั่ง nginx ที่คุมหน้า — ค่านี้คือค่ามาตรฐานที่ DA ใช้
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
$proxy_add_x_forwarded_for แปลว่า "เอาค่าที่ลูกค้าส่งมา แล้วต่อท้ายด้วย IP จริงของลูกค้า" ไม่ใช่การเขียนทับ ถ้าลูกค้าแนบ header มาเองว่า X-Forwarded-For: 8.8.8.8 ผลที่วิ่งเข้าแอปคือ 8.8.8.8, 203.0.113.45
ทีนี้ Symfony ซึ่งเป็นฐานของ Laravel จะไล่ค่าจากขวามาซ้าย ตัดตัวที่อยู่ใน trusted list ทิ้งไปเรื่อย ๆ แล้วหยุดที่ตัวแรกที่ไม่เชื่อ — ตัวนั้นคือคำตอบของ ip() ถ้าเราบอกว่าเชื่อทุกคน มันก็จะตัดทิ้งจนหมดสาย แล้วได้ตัวซ้ายสุด ซึ่งก็คือค่าที่ลูกค้ากรอกมาเอง
rate limit ที่คิดตาม IP หลบได้ด้วยการสุ่ม header ทุก request · การแบน IP ไร้ผลทันที · audit log บันทึกเลขที่คนร้ายเป็นคนเขียนเอง · ระบบที่ยอมให้ IP บางช่วงเข้าถึงหน้า admin ได้ ถูกเดินผ่านด้วย header บรรทัดเดียว ทั้งหมดนี้เงียบสนิท ไม่มี error ให้เห็น
หลักที่ผมยึดคือ trust list ต้องมีเฉพาะ proxy ที่เราคุมเอง บน shared host แบบนี้ก็คือ 127.0.0.1 กับ ::1 เท่านั้น ถ้าอยากได้ IP จริงที่อยู่ก่อนหน้า CDN ด้วย อย่าไปเชื่อจากฝั่ง PHP — ให้ nginx เป็นคนตัดสินตั้งแต่ชั้นแรกด้วย real_ip module แล้ว PHP ค่อยเชื่อ 127.0.0.1 ตามเดิม แบบนี้ช่วง IP ของ CDN อยู่ในไฟล์ config ที่ผู้ใช้ปลอมไม่ได้
# ให้ nginx เขียน REMOTE_ADDR ใหม่จาก header ของ CDN เฉพาะเมื่อมาจากช่วง IP ของ CDN จริง
set_real_ip_from 203.0.113.0/24;
set_real_ip_from 198.51.100.0/22;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
ตรวจให้เห็นกับตาว่าแก้แล้วได้ผล
# ยิงพร้อม header ปลอม ถ้าตั้งถูก ต้องได้ IP จริงของเรา ไม่ใช่ 8.8.8.8
curl -s https://app.example.com/whoami -H 'X-Forwarded-For: 8.8.8.8'
ได้ผลถูกต้องเมื่อค่า ip เป็นเลขจริงของเครื่องที่ยิง ไม่ใช่ 127.0.0.1 (แปลว่ายังไม่เชื่อ proxy) และไม่ใช่ 8.8.8.8 (แปลว่าเชื่อกว้างเกินไป) เสร็จแล้วลบ route ทดสอบทิ้งด้วยครับ อย่าปล่อยไว้
บทเรียนที่ผมเก็บจากเคสนี้คือ เวลาเจออาการ "กดแล้วไม่มีอะไรเกิดขึ้น แต่ log ทุกชั้นเงียบ" ให้เลิกหาคำว่า error แล้วหันไปหาบรรทัด return ที่อยู่ก่อนงานจริงแทน เพราะโค้ดที่ทำงานถูกต้องตามที่เขียนไว้ มันไม่มีเหตุผลจะร้องอะไรออกมาเลย ส่วนใครที่เอา TrustProxies ไปตั้ง ขอให้เลือกค่าที่แคบที่สุดที่ใช้งานได้ อย่าเผลอใส่ดอกจันเพราะเห็นในตัวอย่างบนเน็ต ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย