Python-এ Markdown to HTML converter বানান, XSS-নিরাপদ

Converter regular expression ও parsing শেখায়, কিন্তু আসল শিক্ষা নিরাপত্তা: escape না করা output আক্রমণকারীকে script ঢোকাতে দেয়।

বাঁদিকে Markdown লেখা, ডানদিকে তার HTML রূপ

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 &lt;script&gt;alert(&quot;hi&quot;)&lt;/script&gt; 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 ছিল, আর আউটপুট সেটি &lt;script&gt; হিসেবে দেখায়, যা 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 "-কেও &quot; করে, তাই URL href attribute বন্ধ করে 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>"), "&lt;script&gt;alert(1)&lt;/script&gt;")

    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 বানান.

আরও পড়ুন

Python দিয়ে এলোমেলো CSV পরিষ্কার: নাম, তারিখ, ফোন ও ডুপ্লিকেট

আসল data এলোমেলো। standard library দিয়ে contacts পরিষ্কার করা, প্রতিটি বাতিল row ব্যাখ্যা করা ও test-সহ একটি script বানান।

প্রজেক্ট•৫ মিনিটে পড়া