Python-এ Markdown to HTML converter বানান, XSS-নিরাপদ
Converter regular expression ও parsing শেখায়, কিন্তু আসল শিক্ষা নিরাপত্তা: escape না করা output আক্রমণকারীকে script ঢোকাতে দেয়।

Markdown হলো README ফাইল, ডকুমেন্টেশন ও অনেক blog-এর পেছনের সাধারণ লেখার ফরম্যাট। ছোট converter লেখা regular expression ও text processing অনুশীলনের দারুণ উপায়। এটি ওয়েব ডেভেলপমেন্টের সবচেয়ে গুরুত্বপূর্ণ নিরাপত্তা শিক্ষাগুলোর একটিও দেয়: অবিশ্বস্ত লেখা escape না করে HTML-এ বসাবেন না। কোড ও নিচের আউটপুট প্রকাশের আগে Python 3-এ চালানো হয়েছে।
কী সমর্থন করে
Heading (# থেকে ######), অনুচ্ছেদ, bullet তালিকা, bold, italic, inline code ও লিংক। ইচ্ছা করেই Markdown-এর ছোট উপসেট।
কোড
mini_md.py নামে সংরক্ষণ করুন:
"""A tiny Markdown-to-HTML converter: headings, paragraphs, lists, bold, italic, code and links.
It escapes HTML first, so the input cannot inject tags or scripts."""
import html
import re
def inline(text):
text = html.escape(text) # 1. escape everything first
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
text = re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
def link(match):
label, url = match.group(1), match.group(2)
if not re.match(r"(https?://|/)", url): # 2. block javascript: and other schemes
return label
return f'<a href="{url}" rel="nofollow noopener">{label}</a>'
return re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", link, text)
def convert(markdown):
out, paragraph, items = [], [], []
def flush():
if paragraph:
out.append("<p>" + inline(" ".join(paragraph)) + "</p>")
paragraph.clear()
if items:
out.append("<ul>" + "".join(f"<li>{inline(i)}</li>" for i in items) + "</ul>")
items.clear()
for line in markdown.splitlines():
line = line.rstrip()
heading = re.match(r"(#{1,6})\s+(.*)", line)
bullet = re.match(r"[-*]\s+(.*)", line)
if not line:
flush()
elif heading:
flush()
level = len(heading.group(1))
out.append(f"<h{level}>{inline(heading.group(2))}</h{level}>")
elif bullet:
if paragraph:
flush()
items.append(bullet.group(1))
else:
if items:
flush()
paragraph.append(line)
flush()
return "\n".join(out)
if __name__ == "__main__":
import sys
print(convert(sys.stdin.read()))
চালিয়ে দেখুন
ইনপুট, sample.md:
# My notes
Learning **Python** is *fun*. Read the [docs](https://docs.python.org) and run `python3`.
- first item
- second with **bold**
A [bad link](javascript:alert(1)) and <script>alert("hi")</script> stay harmless.
python3 mini_md.py < sample.md চালালে:
<h1>My notes</h1>
<p>Learning <strong>Python</strong> is <em>fun</em>. Read the <a href="https://docs.python.org" rel="nofollow noopener">docs</a> and run <code>python3</code>.</p>
<ul><li>first item</li><li>second with <strong>bold</strong></li></ul>
<p>A bad link) and <script>alert("hi")</script> stay harmless.</p>
এটি কীভাবে কাজ করে
লাইন ধরে ধরে। convert প্রতিটি লাইন দেখে ঠিক করে সেটি কী: ফাঁকা (block শেষ), heading, bullet বা সাধারণ লেখা। সাধারণ লেখার লাইন অনুচ্ছেদে, bullet লাইন তালিকায় জমে। flush() এ পর্যন্ত জমা block লিখে দেয়।
Regular expression। (#{1,6})\s+(.*) এক থেকে ছয়টি #, একটি ফাঁকা, তারপর heading-এর লেখা মেলায়। \*\*(.+?)\*\* দুই তারকার মাঝের লেখা মেলায়, আর ? একে non-greedy করে, মানে লাইনের শেষ **-এর বদলে প্রথম বন্ধ **-এ থামে। প্রতিস্থাপনে \1 ধরা লেখাকে HTML tag-এর ভেতরে ফিরিয়ে বসায়।
ক্রম গুরুত্বপূর্ণ। *italic*-এর আগে **bold** সামলাই, নইলে এক-তারকার নিয়ম দুই-তারকার অংশ খেয়ে ফেলবে।
নিরাপত্তার শিক্ষা: আগে escape করুন
Sample-এর শেষ লাইন দেখুন। ইনপুটে আসল <script> tag ছিল, আর আউটপুট সেটি <script> হিসেবে দেখায়, যা browser নিরীহ লেখা হিসেবে দেখায়। কারণ inline()-এর একেবারে প্রথম ধাপ html.escape(text), যা <, >, & ও quote-কে নিরাপদ কোডে বদলায়।
এটি বাদ দিলে যে কেউ Markdown লিখতে পারে (comment, profile bio) সে <script> ঢুকিয়ে অন্য ব্যবহারকারীর browser-এ কোড চালাতে পারত। এটিই cross-site scripting (XSS)। আগে escape করুন, তারপর নিজের নিরাপদ tag যোগ করুন।
আরও দুটি খুঁটিনাটি:
- লিংকের scheme।
[click](javascript:alert(1))-এর মতো লিংক click করলে script চালাত। আমরা শুধুhttp://,https://বা/দিয়ে শুরু ঠিকানা অনুমতি দিই, নয়তো শুধু label ছাপি। খারাপ scheme একটি একটি করে আটকানোর চেয়ে এই "allow list" পদ্ধতি নিরাপদতর। - Quote অক্ষর।
html.escape"-কেও"করে, তাই URLhrefattribute বন্ধ করেonmouseover=...-এর মতো কিছু যোগ করতে পারে না। এর জন্য test আছে। - অবিশ্বস্ত লেখার লিংকে
rel="nofollow noopener"যোগ করি।
Test
test_mini_md.py নামে সংরক্ষণ করুন:
import unittest
from mini_md import convert, inline
class MarkdownTest(unittest.TestCase):
def test_heading_and_paragraph(self):
self.assertEqual(convert("# Title\n\nHello"), "<h1>Title</h1>\n<p>Hello</p>")
def test_inline_styles(self):
self.assertEqual(inline("**b** *i* `c`"), "<strong>b</strong> <em>i</em> <code>c</code>")
def test_list(self):
self.assertEqual(convert("- a\n- b"), "<ul><li>a</li><li>b</li></ul>")
def test_html_is_escaped(self):
self.assertEqual(inline("<script>alert(1)</script>"), "<script>alert(1)</script>")
def test_dangerous_link_scheme_is_removed(self):
self.assertNotIn("<a", inline("[x](javascript:alert(1))"))
self.assertIn('href="https://example.com"', inline("[x](https://example.com)"))
def test_quotes_cannot_break_out_of_href(self):
out = inline('[x](https://a.com/"onmouseover="alert(1))')
self.assertNotIn('"onmouseover', out)
if __name__ == "__main__":
unittest.main()
$ python3 -m unittest -v
Ran 6 tests in 0.003s
OK
এখানে নিরাপত্তার test-গুলোই সবচেয়ে মূল্যবান। অবিশ্বস্ত ইনপুট সামলালেই এমন test লিখুন যা তাকে আক্রমণ করার চেষ্টা করে।
সৎ সীমা
এটি শেখার প্রজেক্ট, পূর্ণ Markdown parser নয়:
- পূর্ণ Markdown নয়। সংখ্যাযুক্ত তালিকা, code block, nested তালিকা, table, ছবি বা blockquote নেই, এবং bold-এর ভেতর একাধিক লাইনের লেখা সমর্থিত নয়।
- Sample আউটপুটে একটি দৃশ্যমান bug:
[bad link](javascript:alert(1))"bad link"-এর পরে একটি বাড়তি)রেখে গেছে। আমাদের লিংক প্যাটার্ন প্রথম)-এ থামে, তাই বন্ধনীসহ URL সামলানো যায় না। ফল তবু নিরাপদ, শুধু সুন্দর নয়। আসল parser এটি regex নয়, যথাযথ parser দিয়ে সামলায়। - Regex parser ভঙ্গুর। Markdown-এর অনেক ব্যতিক্রম আছে। আসল প্রজেক্টে রক্ষণাবেক্ষণ করা library (Python-Markdown, markdown-it, marked) ব্যবহার করুন এবং এর তৈরি HTML-কে
bleachবা DOMPurify-র মতো library দিয়ে sanitise করুন। বেশিরভাগ Markdown library ডিফল্টে raw HTML অনুমতি দেয়, আর escape বা sanitise করা আপনার দায়িত্ব। - দ্বিতীয় স্তর হিসেবে escape-এর সঙ্গে Content-Security-Policy header-ও যোগ করুন।
চ্যালেঞ্জ
১. সংখ্যাযুক্ত তালিকা (1. item) যোগ করুন।
২. fenced code block (তিনটি backtick) যোগ করুন এবং এর ভেতরের লেখা escape করুন।
৩. URL-এ বন্ধনীর সমস্যা ঠিক করুন।
৪. Blockquote (> text) যোগ করুন।
৫. ফাইলে পূর্ণ HTML পাতা লেখে এমন command-line option যোগ করুন।
৬. একই ইনপুটে আপনার আউটপুট আসল library-র সঙ্গে তুলনা করুন।
মূল কথা: লেখা parse করা মূলত সাবধানী প্যাটার্ন মেলানো, কিন্তু অবিশ্বস্ত লেখা নিরাপদে সামলানোই আসল দক্ষতা। আগে escape করুন, URL scheme allow-list করুন, প্রতিরক্ষা test করুন, এবং production-এ রক্ষণাবেক্ষণ করা library ও sanitising ব্যবহার করুন।
হাতে-কলমে প্রজেক্ট সিরিজ, পর্ব ২ (পুনর্ব্যবহারযোগ্য টুল): 4 নম্বর (মোট 5)। আগের লেখা: Token bucket algorithm দিয়ে Node.js-এ rate limiter বানান.


