
Python string.Template: ให้ผู้ใช้แก้ข้อความแจ้งเตือนเองได้ โดยไม่เปิดช่องให้ล้วงข้อมูลในอ็อบเจกต์
ร้านค้าออนไลน์หลายร้านอยากให้แอดมินแก้ข้อความแจ้งเตือนเองได้ เช่น “สวัสดีคุณ {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 ของคุณ ดูว่าผลตรงกันไหม