งูต้นฉบับต่อรางเส้นทางเข้ากับสถานีสีเขียวเดิม ขณะที่รางอำพันซึ่งเลี้ยวออกนอกกำแพงถูกสกัดไว้

Python urljoin: ต่อ URL โดยไม่เปิดทางให้ input เปลี่ยน host

พี่โชว์Engineering

urllib.parse.urljoin ทำตามกฎ URL มาตรฐาน ดังนั้น argument ที่เป็น URL เต็มหรือเริ่มด้วย // สามารถเปลี่ยน host จาก base ได้ พฤติกรรมนี้ถูกต้องสำหรับ browser แต่เสี่ยงเมื่อ path มาจากผู้ใช้และโปรแกรมนำผลไป redirect หรือ fetch ต่อครับ

WHY: ป้ายทางแยกอาจพาออกจากรั้ว

เราตั้งต้นที่โกดังของบริษัทและคิดว่าต่อเพียงเลขชั้น แต่ป้ายที่ผู้ใช้ส่งมาอาจเขียนเส้นทางไปอีกจังหวัด การ join จึงต้องตามด้วย policy check ว่า scheme และ host ยังอยู่ในขอบที่ไว้ใจ

from urllib.parse import urljoin, urlsplit
import unittest


def join_same_origin(base: str, candidate: str) -> str:
    base_parts = urlsplit(base)
    joined = urljoin(base, candidate)
    joined_parts = urlsplit(joined)
    if joined_parts.scheme.lower() != base_parts.scheme.lower():
        raise ValueError("scheme changed")
    if joined_parts.netloc.lower() != base_parts.netloc.lower():
        raise ValueError("origin changed")
    return joined


class SafeJoinTests(unittest.TestCase):
    def test_relative_path_stays_on_origin(self) -> None:
        self.assertEqual(
            join_same_origin("https://example.test/app/", "reports/7"),
            "https://example.test/app/reports/7",
        )

    def test_absolute_and_scheme_relative_urls_are_rejected(self) -> None:
        for value in ("https://evil.test/x", "//evil.test/x"):
            with self.subTest(value=value), self.assertRaises(ValueError):
                join_same_origin("https://example.test/app/", value)


if __name__ == "__main__":
    unittest.main()

HOW: same-origin ยังไม่ใช่ SSRF defense ทั้งชุด

ตัวอย่างเหมาะกับ base ที่กำหนดโดยระบบและต้องการกัน host เปลี่ยน แต่ production ยังต้อง normalize port, IDNA hostname, credential ใน URL, redirect chain และ DNS resolution ตาม threat model หากนำ URL ไปเรียก network ต้องบล็อก private/loopback/link-local ranges หลัง resolve และตรวจทุก redirect ไม่ใช่ตรวจ string ครั้งแรกแล้วจบ

ถ้าโจทย์รับเฉพาะ path ภายในเว็บ ทางที่ง่ายกว่าคือปฏิเสธ input ที่มี scheme หรือ netloc ตั้งแต่ต้น แล้วประกอบ route ด้วย API ของ framework อย่า decode ซ้ำหลายรอบจน %2f หรือ backslash เปลี่ยนความหมายต่างกันระหว่าง proxy กับแอป

Recap

  • ถือ argument ขวาของ urljoin เป็น URL ไม่ใช่ path ธรรมดา
  • ตรวจ scheme และ host หลัง join
  • สำหรับ network fetch ให้ตรวจ DNS, IP ranges และ redirect ทุก hop
  • ลดรูป input เป็น internal path เมื่อทำได้
  • ทดสอบ absolute URL, //host, port, encoded separator และ redirect