ตั้ง trustProxies ให้ Laravel ที่อยู่หลัง Cloudflare โดยไม่เชื่อทุก IP

trustProxies(at: "*") แก้ลิงก์ http กับ IP ของผู้ใช้ที่เพี้ยนหลัง Cloudflare ได้ แต่ทำให้ผู้ใช้ปลอม IP ของตัวเองได้แม้ request จะผ่าน Cloudflare มา โพสต์นี้อธิบายเหตุผล และวิธีตั้งให้เชื่อเฉพาะ IP ของ Cloudflare กับ header สองตัว พร้อม test ที่กันไม่ให้ใครแก้กลับเป็น *

เผยแพร่
เวลาอ่าน
4 นาที
ในโพสต์นี้ 6 หัวข้อ
  1. Laravel รู้จักผู้ใช้จริงผ่าน header ที่ proxy ส่งมา
  2. * ทำให้ผู้ใช้ปลอม IP ได้แม้จะผ่าน Cloudflare
  3. เชื่อเฉพาะ IP ของ Cloudflare และ header สองตัว
  4. เขียน test กันไม่ให้ใครแก้กลับเป็น *
  5. ช่วง IP ของ Cloudflare เปลี่ยนได้
  6. ปิดทางที่ไม่ผ่าน Cloudflare ด้วย

พอเอาเว็บ Laravel ไปไว้หลัง Cloudflare หรือหลัง nginx ที่ทำ HTTPS ให้ อาการแรกที่มักเจอคือลิงก์และ asset ออกมาเป็น http:// เบราว์เซอร์บล็อกเป็น Mixed Content แล้ว Livewire หรือ Inertia ก็หยุดทำงาน อีกอาการที่ไม่ค่อยมีใครสังเกตคือ $request->ip() ได้ IP ของ Cloudflare ทุก request rate limit ที่นับตาม IP จึงนับผู้ใช้หลายคนเป็นคนเดียว

คำตอบที่หาเจอเร็วที่สุดคือบรรทัดนี้

$middleware->trustProxies(at: '*');

ทั้งสองอาการหายจริง แต่ก็เปิดช่องให้ปลอม IP ไปด้วย

Laravel รู้จักผู้ใช้จริงผ่าน header ที่ proxy ส่งมา

เมื่อมี proxy อยู่ตรงกลาง คนที่ต่อเข้า PHP คือ proxy ไม่ใช่ผู้ใช้ REMOTE_ADDR จึงเป็น IP ของ Cloudflare และ request ระหว่าง proxy กับเซิร์ฟเวอร์ก็อาจเป็น HTTP ธรรมดา ข้อมูลของผู้ใช้จริงมากับ header

  • X-Forwarded-For รายการ IP ที่ request ผ่านมา
  • X-Forwarded-Proto ผู้ใช้เปิดเว็บด้วย http หรือ https
  • X-Forwarded-Host โดเมนที่ผู้ใช้พิมพ์

ใครก็ส่ง header พวกนี้มาเองได้ TrustProxies จึงบอก Laravel ว่าให้เชื่อ header เหล่านี้เฉพาะเมื่อ request มาจาก IP ที่เราระบุไว้

* ทำให้ผู้ใช้ปลอม IP ได้แม้จะผ่าน Cloudflare

ตามเอกสารของ Cloudflare ถ้า request มี X-Forwarded-For ติดมาอยู่แล้ว Cloudflare จะต่อ IP ที่ต่อเข้ามาไว้ท้ายรายการ ผู้ใช้ที่ส่ง X-Forwarded-For: 1.2.3.4 มาเอง จะไปถึงเซิร์ฟเวอร์เป็น 1.2.3.4, <IP จริง>

Laravel ไล่รายการนี้จากขวาไปซ้าย ข้าม IP ที่อยู่ในรายการ proxy ที่เชื่อถือ แล้วหยิบตัวแรกที่ไม่ใช่ proxy เป็น IP ของผู้ใช้ ถ้าเชื่อแค่ Cloudflare ตัวที่ได้คือ IP จริงที่ Cloudflare เติมให้ แต่ถ้าเชื่อ * ทุก IP ในรายการก็นับเป็น proxy หมด Laravel จึงย้อนไปหยิบตัวซ้ายสุด ซึ่งเป็นค่าที่ผู้ใช้พิมพ์มาเอง

เราลองส่ง request แบบเดียวกันเข้า Request ของ Symfony ที่ Laravel ใช้ ได้ผลแบบนี้

trust '*'           ip=1.2.3.4
trust Cloudflare    ip=203.0.113.7

ผลที่ตามมามีหลายเรื่อง

  • rate limit ใช้ไม่ได้ คนที่ลองรหัสผ่านซ้ำ ๆ หรือยิงฟอร์มติดต่อเปลี่ยน IP ปลอมทุกครั้ง ก็ไม่เคยโดนจำกัด
  • log และสถิติผิด IP ที่บันทึกไว้ตอนมีเหตุผิดปกติเชื่อไม่ได้
  • allowlist ตาม IP หลุด หน้า admin ที่เปิดให้เฉพาะ IP ของออฟฟิศ ใครพิมพ์ IP นั้นมาก็เข้าได้

ส่วน X-Forwarded-Host อันตรายกว่า ถ้า Laravel เชื่อ header นี้ ผู้ใช้ก็กำหนดโดเมนในลิงก์ที่แอปสร้างได้ ลิงก์รีเซ็ตรหัสผ่านในอีเมลอาจชี้ไปที่โดเมนของคนอื่น ซึ่งค่าเริ่มต้นของ TrustProxies เชื่อ header นี้ด้วย

เชื่อเฉพาะ IP ของ Cloudflare และ header สองตัว

->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(headers: Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_PROTO);
    $middleware->trustHosts();
})
  • ไม่ส่ง at: เมื่อไม่ได้ระบุ IP TrustProxies จะอ่านรายการจาก config('trustedproxy.proxies') เอง เราจึงเก็บรายการยาว ๆ ไว้ในไฟล์ config แยก แล้วเขียนวันที่เช็กไว้ในคอมเมนต์
  • ช่วง IP ของ Cloudflare คัดจาก cloudflare.com/ips ทั้ง IPv4 และ IPv6
  • loopback สำหรับ request ที่มาจากเครื่องเดียวกัน เช่น nginx ที่ส่งต่อให้ PHP ในเครื่อง หรือ Cloudflare Tunnel ที่ต่อเข้าแอปจาก 127.0.0.1
  • เชื่อแค่ FOR กับ PROTO สองตัวนี้คือสิ่งที่เราต้องการจริง ส่วน HOST, PORT และ PREFIX ไม่ต้องเชื่อ เพราะโดเมนของเว็บมีอยู่แล้วใน APP_URL

trustHosts() ปิดอีกทาง คือรับเฉพาะ request ที่ Host ตรงกับโดเมนใน APP_URL หรือ subdomain ของโดเมนนั้น ที่เหลือตอบ 400 ไปเลย middleware นี้ข้ามให้เองบนเครื่อง local และตอนรัน test

เขียน test กันไม่ให้ใครแก้กลับเป็น *

วันหนึ่งที่ Livewire ขึ้น Mixed Content อีก คนในทีมหรือ AI agent ก็อาจแก้กลับเป็น at: '*' เพราะเป็นคำตอบที่หาเจอง่ายที่สุด test สั้น ๆ ช่วยกันเรื่องนี้ได้ repo ของเว็บนี้มี test แบบนี้

beforeEach(function (): void {
    Route::get('/_test/client-ip', fn (): ?string => request()->ip());
});

it('ignores addresses a client prepends to the Cloudflare forwarded chain', function (): void {
    $this->withServerVariables(['REMOTE_ADDR' => '172.68.10.20'])
        ->get('/_test/client-ip', ['X-Forwarded-For' => '198.51.100.1, 203.0.113.9'])
        ->assertSeeText('203.0.113.9');
});

it('ignores a forwarded host even from a trusted proxy', function (): void {
    Route::get('/_test/host', fn (): string => request()->getHost());

    $this->withServerVariables(['REMOTE_ADDR' => '172.68.10.20'])
        ->get('/_test/host', ['X-Forwarded-Host' => 'evil.example'])
        ->assertDontSeeText('evil.example');
});

172.68.10.20 อยู่ในช่วงของ Cloudflare ถ้าใครเปลี่ยนเป็น * test แรกจะได้ 198.51.100.1 แล้ว fail พร้อมชื่อ test ที่บอกว่าพังเรื่องอะไร ใน repo จริงยังมี test ว่า IP จาก loopback และ IPv6 ของ Cloudflare ส่งต่อได้ และ X-Forwarded-Proto ยังใช้ได้ด้วย

ช่วง IP ของ Cloudflare เปลี่ยนได้

Cloudflare ไม่ค่อยเปลี่ยนรายการนี้ แต่ก็เปลี่ยนได้ ถ้ามีช่วงใหม่แล้วเราไม่ได้เพิ่ม request ที่มาจากช่วงนั้นจะได้ IP ของ Cloudflare แทนผู้ใช้อีกครั้ง ทางที่ง่ายคือเปิดหน้า cloudflare.com/ips เทียบเป็นระยะ แล้วแก้วันที่ในคอมเมนต์ ถ้าไม่อยากดูแลเอง แพ็กเกจ monicahq/laravel-cloudflare โหลดรายการจาก Cloudflare มาเก็บใน cache ให้

ปิดทางที่ไม่ผ่าน Cloudflare ด้วย

trustProxies แก้ฝั่งแอป ถ้าเราดูแลเซิร์ฟเวอร์เองยังมีอีกสองชั้นที่ควรทำ

  • รับเฉพาะ request จาก Cloudflare ตั้ง firewall หรือ nginx ให้ปฏิเสธ IP อื่น คนที่รู้ IP ของเซิร์ฟเวอร์จะได้ยิงตรงเข้ามาไม่ได้ หรือใช้ Cloudflare Tunnel ซึ่งไม่ต้องเปิด port ให้ใครเลย
  • ให้ nginx คืน IP จริงก่อนถึง PHP ด้วย set_real_ip_from กับ real_ip_header CF-Connecting-IP REMOTE_ADDR ที่ Laravel เห็นจะเป็น IP ของผู้ใช้ตั้งแต่ต้น และไม่อยู่ในรายการ proxy Laravel จึงไม่อ่าน X-Forwarded-For ที่ผู้ใช้ส่งมาเองเลย

บน shared hosting ที่แก้ nginx ไม่ได้ trustProxies แบบในโพสต์นี้คือสิ่งที่เราทำได้จากฝั่งแอป และพอแล้วสำหรับเรื่อง IP กับลิงก์ ค่านี้เป็นหนึ่งในค่าที่เราตั้งตั้งแต่วันแรกของโปรเจกต์ Laravel ใหม่ ส่วนงานอื่นกับ Cloudflare อย่างล้าง cache หรือตั้ง WAF จาก artisan ลองดูแพ็กเกจ laravel-cloudflare ได้ครับ

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →