นากการ์ตูนสีมิ้นต์ใช้ตรายางเติมช่องว่างบนแผ่นแม่แบบการ์ดอวยพร ข้างลิ้นชักเอกสารส่วนตัวที่ล็อกด้วยแม่กุญแจสีเขียวอมฟ้า

Python string.Template: ให้ผู้ใช้แก้ข้อความแจ้งเตือนเองได้ โดยไม่เปิดช่องให้ล้วงข้อมูลในอ็อบเจกต์

น้องกายEngineering

ร้านค้าออนไลน์หลายร้านอยากให้แอดมินแก้ข้อความแจ้งเตือนเองได้ เช่น “สวัสดีคุณ {name} ยอดค้างชำระ {amount} บาท” วิธีที่ง่ายที่สุดคือเอาข้อความที่แอดมินพิมพ์ไปเรียก .format() ตรงๆ แต่วิธีนี้มีช่องโหว่ที่หลายคนไม่ทันคิดครับ

วันนี้ผมจะเล่าว่าช่องนั้นคืออะไร และใช้ string.Template ในไลบรารีมาตรฐานปิดมันยังไง

WHY: str.format กับข้อความจากผู้ใช้ เสี่ยงตรงไหน

ตาม Format String Syntax ช่อง {...} ไม่ได้แค่แทนชื่อตัวแปร มันเขียน .ชื่อ เพื่อดึง attribute (ผ่าน getattr()) และ [key] เพื่อดึงสมาชิกได้ด้วย

สมมติโค้ดเราส่งอ็อบเจกต์ผู้ใช้เข้าไปทั้งก้อน:

  • เราตั้งใจให้แอดมินเขียน {u.name}
  • แต่ถ้ามีคนเขียน {u.staff_memo} และอ็อบเจกต์มี attribute ชื่อ staff_memo (บันทึกภายในของพนักงาน) อยู่จริง ค่านั้นจะถูกพิมพ์ออกไปในข้อความ

คนที่แก้ template ได้ จึงอ่านข้อมูลอะไรก็ได้ที่ “เดินถึง” จากอ็อบเจกต์ที่ส่งเข้าไป ซึ่งเกินสิทธิ์ที่เราตั้งใจให้

HOW: string.Template ทำงานยังไง

Template ใช้ไวยากรณ์ที่เรียบง่ายกว่ามาก:

  • $name หรือ ${name} แทนค่าจาก dict ที่เราส่งเข้าไป
  • $$ คือเครื่องหมาย $ ธรรมดา
  • ไม่มีการดึง attribute ไม่มีการดึง index ถ้าเขียน $u.staff_memo จะแทนแค่ $u แล้วปล่อย .staff_memo ไว้เป็นข้อความ

มีเมธอดหลัก 2 ตัว:

  • substitute(...) ถ้าขาดค่าตัวไหน โยน KeyError เหมาะกับตอนที่ต้องการให้ครบเท่านั้น
  • safe_substitute(...) ถ้าขาดค่า จะทิ้ง $name ไว้ตามเดิม ไม่โยน error

ตั้งแต่ Python 3.11 ยังมี get_identifiers() คืนรายชื่อตัวแปรใน template และ is_valid() ตรวจว่า template เขียนถูกไวยากรณ์ไหม

จุดที่ต้องรู้ก่อนใช้

  • เอกสารเตือนเองว่า safe_substitute อาจ “ไม่ safe” ในอีกความหมาย เพราะมันเงียบใส่ template ที่เขียนผิด เช่น $ ห้อยท้าย หรือปีกกาไม่ครบ
  • ทางที่ดีคือ ตรวจ template ตอนแอดมินกดบันทึก ด้วย is_valid() และเช็กว่า get_identifiers() มีแต่ชื่อที่เราอนุญาต
  • ส่งเข้าไปแค่ค่าที่เป็นข้อความหรือตัวเลขที่ตั้งใจให้ใช้ ไม่ส่งอ็อบเจกต์ทั้งก้อน
  • Template ไม่ได้ escape HTML ให้ ถ้าเอาผลไปแสดงบนเว็บ ยังต้อง escape ตามปกติ

โค้ดพร้อม unittest

import string
import unittest

ALLOWED = {"name", "amount", "due"}


class User:
    def __init__(self):
        self.name = "สมชาย"
        self.staff_memo = "ลูกค้าค้างชำระ 3 งวด"  # ข้อมูลภายใน ไม่ควรหลุดไปในข้อความ


def validate_template(text):
    """เรียกตอนแอดมินกดบันทึก template"""
    t = string.Template(text)
    if not t.is_valid():
        raise ValueError("template เขียนไม่ถูกไวยากรณ์")
    unknown = set(t.get_identifiers()) - ALLOWED
    if unknown:
        raise ValueError(f"ใช้ตัวแปรที่ไม่อนุญาต: {sorted(unknown)}")
    return t


def render(text, **values):
    t = validate_template(text)
    return t.safe_substitute({k: str(v) for k, v in values.items() if k in ALLOWED})


class TemplateTest(unittest.TestCase):
    def test_format_can_reach_attributes(self):
        leaked = "{u.staff_memo}".format(u=User())
        self.assertEqual(leaked, "ลูกค้าค้างชำระ 3 งวด")

    def test_template_does_not_follow_attributes(self):
        out = string.Template("$u.staff_memo").safe_substitute(u="x")
        self.assertEqual(out, "x.staff_memo")

    def test_substitute_requires_all_values(self):
        t = string.Template("สวัสดี $name ยอด ${amount} บาท")
        with self.assertRaises(KeyError):
            t.substitute(name="สมชาย")

    def test_safe_substitute_keeps_missing(self):
        t = string.Template("สวัสดี $name ยอด ${amount} บาท")
        self.assertEqual(t.safe_substitute(name="สมชาย"),
                         "สวัสดี สมชาย ยอด ${amount} บาท")

    def test_dollar_escape(self):
        self.assertEqual(string.Template("ราคา $$9 ส่ง $name").substitute(name="ฟรี"),
                         "ราคา $9 ส่ง ฟรี")

    def test_validate_rejects_bad_templates(self):
        with self.assertRaises(ValueError):
            validate_template("ยอด $")  # $ ห้อยท้าย
        with self.assertRaises(ValueError):
            validate_template("บันทึก $memo")  # ตัวแปรนอกรายการ

    def test_render_happy_path(self):
        msg = render("คุณ $name ค้าง $amount บาท ภายใน $due", name="สมชาย",
                     amount="1,250", due="31 ต.ค.")
        self.assertEqual(msg, "คุณ สมชาย ค้าง 1,250 บาท ภายใน 31 ต.ค.")


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

ตอนตรวจบทความ ผมรันโค้ดนี้ด้วย python3 -I -B บน Python 3.14 ของเครื่องนี้ เทสต์ผ่านทุกข้อ ส่วน get_identifiers() และ is_valid() ต้องใช้ Python 3.11 ขึ้นไป

เลือกใช้อะไรเมื่อไหร่

สถานการณ์ใช้
ข้อความที่นักพัฒนาเขียนเองในโค้ดf-string หรือ str.format ได้ตามปกติ
template ที่ผู้ใช้หรือแอดมินแก้ได้string.Template + ตรวจตอนบันทึก
ต้องการให้ค่าครบทุกตัวเท่านั้นsubstitute() แล้วจับ KeyError
ข้อความ preview ระหว่างแก้safe_substitute()
ต้องการ loop/เงื่อนไขใน templateใช้ template engine ที่มีโหมด sandbox และอ่านเอกสารความปลอดภัยของมันก่อน

Recap

  • อย่าเอาข้อความที่ผู้ใช้แก้ได้ไปเข้า str.format พร้อมอ็อบเจกต์ทั้งก้อน
  • ใช้ string.Template กับ $name แทน
  • ตรวจ template ตอนบันทึกด้วย is_valid() และ get_identifiers() (Python 3.11+)
  • ส่งเข้าไปเฉพาะค่าที่อนุญาต เป็นข้อความธรรมดา
  • ผลลัพธ์ที่ไปแสดงบนเว็บ ยังต้อง escape HTML
  • ก๊อปเทสต์ด้านบนไปรันบน Python ของคุณ ดูว่าผลตรงกันไหม