برچسبِ یک حسگر ممکن است علامتِ آمپرسند، زاویه یا نقل‌قول داشته باشد. اگر آن را مستقیم داخلِ قالب بگذاریم، داده و ساختار با هم مخلوط می‌شوند. اما یک فراردهیِ نامشخص برای همهٔ جایگاه‌ها هم قراردادِ مناسبی نیست. در این درس فقط دو قالبِ ثابت را با پایتون بررسی می‌کنیم: متنِ داخلِ یک عنصر و مقدارِ ویژگیِ معمولیِ title که میانِ نقل‌قولِ دوتایی قرار دارد. آزمایش، مرورگر، حملهٔ واقعی یا پاک‌سازِ عمومیِ HTML نیست.

۱. دادهٔ نمایشی را از ساختارِ HTML جدا کنیم

نامِ تگ و ویژگی را از دادهٔ ورودی نگیریم
تصویر مفهومی: نامِ تگ و ویژگی را از دادهٔ ورودی نگیریم؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

نامِ تگ و ویژگی، متعلق به قالبِ ثابت‌اند؛ برچسب فقط داده است. در مسیرِ نخست، قالب یک عنصرِ p دارد و برچسب میانِ شروع و پایانش قرار می‌گیرد. در مسیرِ دوم، یک div با تنها ویژگیِ title داریم و بدنه‌اش خالی است. فراخواننده فقط نامِ یکی از این دو زمینه را انتخاب می‌کند؛ نمی‌تواند نامِ عنصر، ویژگی، نشانیِ پیوند یا قطعهٔ برنامه وارد کند. همین محدودیت، بخشِ مهمِ مسئلهٔ آزمون است.

ورودیِ نمایشی باید str دقیق با طولِ صفر تا ۴۸ نقطهٔ کد باشد. حروفِ چاپ‌پذیرِ ASCII و فهرستِ صریحی از حروفِ فارسیِ نمونه پذیرفته می‌شوند؛ کنترلِ خط، صفر، surrogate، شکلک و نیم‌فاصله در این دامنه نیستند. این فهرست، اعتبارسنجیِ عمومیِ متنِ فارسی نیست و محدودیتش را نباید به رابطِ واقعی تحمیل کرد. سقفِ طول هم بودجهٔ حافظه یا تضمینِ امنیتِ سامانه نیست؛ فقط فضای آزمایشِ محدود را روشن می‌کند.

راهنمایِ خروجیِ OWASP توضیح می‌دهد که زمینه‌های HTML، ویژگی، JavaScript، CSS و URL یکسان تفسیر نمی‌شوند. کدِ ما چهار زمینهٔ آخرِ ویژه را به‌صورتِ عمومی پشتیبانی نمی‌کند؛ تنها ویژگیِ متنیِ ثابتِ title مجاز است. پذیرفتنِ برچسب، مجوزِ قراردادنِ آن در رویدادِ onclick، سبک، script یا HTML خام نیست. این‌ها مسیرهای دیگری با قرارداد و ابزارِ مناسبِ خودشان‌اند.

۲. متنِ عنصر و مقدارِ ویژگی، زمینهٔ یکسانی ندارند

مرزِ ویژگیِ نقل‌قول‌دار با متن متفاوت است
تصویر مفهومی: مرزِ ویژگیِ نقل‌قول‌دار با متن متفاوت است؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

طبقِ مستنداتِ html.escape، علامت‌های آمپرسند و دو زاویه به ارجاعِ نویسه تبدیل می‌شوند. با quote درست، دو نوعِ نقل‌قول نیز تبدیل می‌شوند. در متنِ عنصرِ معمولیِ ثابت، نقل‌قول مرزِ ویژگی نیست و مثال از quote نادرست استفاده می‌کند. در ویژگیِ title میانِ نقل‌قولِ دوتایی، quote درست لازم است تا نقل‌قولِ خودِ داده، قالب را زودتر نبندد.

برچسبِ اصلی، حرفِ A، آمپرسند، B میانِ زاویه و دو عبارتِ نقل‌قول‌دار دارد. انتظارِ دستی این است که در هر دو مسیر، همان برچسب را به‌عنوانِ داده بازیابی کنیم؛ در مسیرِ title دقیقاً یک ویژگی داشته باشیم و در مسیرِ متن هیچ ویژگی نداشته باشیم. خروجیِ نوشتاریِ این دو قالب لازم نیست برابر باشد، زیرا جایگاهِ درج متفاوت است. دقتِ عددی یا اندازه‌گیریِ حسگر در این برچسب وجود ندارد.

یک نمونهٔ عمداً نادرست نیز داریم: دادهٔ شاملِ نقل‌قول و عبارتِ data-note را با quote نادرست داخلِ ویژگی می‌گذاریم. مشاهده‌گر به‌جایِ تنها title، دو ویژگی می‌بیند. این شاهد فقط شکستِ مرزِ قالب را نشان می‌دهد؛ کدِ فعال یا سوءاستفادهٔ مرورگری اجرا نمی‌کند. همان داده در مسیرِ درست، تنها یک title با مقدارِ کاملِ اولیه می‌سازد. افزودنِ نقل‌قول دورِ قالب، بدونِ محافظتِ نقل‌قولِ داخلِ داده کافی نیست.

۳. دادهٔ خام و خطرِ دوباره‌فراردهی

فراردهیِ دوباره می‌تواند دادهٔ نمایشی را تغییر دهد
تصویر مفهومی: فراردهیِ دوباره می‌تواند دادهٔ نمایشی را تغییر دهد؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

فراردهیِ دوباره الزاماً نتیجهٔ یک بار را نگه نمی‌دارد. برای دادهٔ خامِ آمپرسند، یک بار فراردهی یک ارجاعِ نویسه می‌سازد. اگر همان خروجیِ رمزگذاری‌شده را دوباره دادهٔ خام فرض کنیم، آمپرسندِ داخلِ ارجاع نیز تبدیل می‌شود. پس از یک مرحلهٔ خواندن، به‌جایِ آمپرسندِ اولیه، متنِ نوشتاریِ ارجاع را خواهیم داشت. آزمون، این تفاوت را صریح گزارش می‌کند.

اگر کاربر واقعاً رشته‌ای با املای یک entity وارد کرده باشد، آن املا باید داده باقی بماند؛ نباید بی‌اجازه به علامتِ دیگری تبدیل شود. نمونهٔ جداگانه همین حالت را می‌سنجد. تابعِ رندر، دادهٔ رمزگذاری‌شده را تشخیص نمی‌دهد و پرچمِ اعتمادِ پنهان نمی‌سازد. بهتر است مرزِ داده و نمایش در طراحی معلوم باشد؛ افزودنِ یک مرحلهٔ مبهمِ unescape پیش از هر خروجی، قراردادِ ورودی و کاربردهای دیگر را تغییر می‌دهد.

قراردادِ JSON حسگر، اعتبارِ طرح و پیامِ خطا را بررسی می‌کرد؛ پذیرشِ داده در آن مرحله، فراردهیِ HTML نیست. گزارشِ ساختاریافتهٔ حسگر نیز فراردهیِ متناسب با JSON دارد. یک رشتهٔ درست در قالبِ ثبتِ رویداد، خودکار برای درج در هر جایِ صفحه آماده نمی‌شود. مقصدِ مصرف، بخشی از معنایِ خروجی است.

۴. کد کامل و مرجعِ مستقلِ نویسه‌ها

نمایشِ نوشتاریِ متفاوت می‌تواند دادهٔ یکسانی داشته باشد
تصویر مفهومی: نمایشِ نوشتاریِ متفاوت می‌تواند دادهٔ یکسانی داشته باشد؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

فایل را با نامِ html_output_context.py ذخیره کنید. اجرای ثبت‌شده با CPython 3.14.5 و فقط کتابخانهٔ استاندارد انجام شده است. تابع پیش از ساختِ رشته، نوع، طول، حروف و نامِ زمینه را کنترل می‌کند. هیچ تگ یا ویژگیِ دلخواهی به قالب اضافه نمی‌شود. داده‌های نمونه تألیفی‌اند؛ دریافتِ صفحه، نصبِ چارچوب، اجرای JavaScript یا تغییرِ تنظیماتِ سایت در این برنامه وجود ندارد.

"""Own two-template HTML output contract; not a browser or general sanitizer."""
from html import escape
from html.parser import HTMLParser
import itertools
import json
import platform
import re


MAX_LABEL = 48
PERSIAN = frozenset('\u062d\u0633\u06af\u0631\u0648\u0644\u062a\u0622\u0628\u06cc')


def validate(label, context):
    if type(context) is not str:
        raise TypeError('Exact context string required')
    if context not in ('text', 'title'):
        raise ValueError('Only own fixed text/title contexts are supported')
    if type(label) is not str:
        raise TypeError('Exact unencoded label string required')
    if len(label) > MAX_LABEL:
        raise ValueError('Label exceeds own48-codepoint limit')
    if any(not (32 <= ord(char) <= 126 or char in PERSIAN) for char in label):
        raise ValueError('Character outside own printable-label profile')


def render_label(label, context):
    """Caller chooses one fixed template, never a tag/attribute/URL/code name."""
    validate(label, context)
    if context == 'text':
        return '<p>' + escape(label, quote=False) + '</p>'
    return '<div title="' + escape(label, quote=True) + '"></div>'


class Observation(HTMLParser):
    """Observe callbacks only. This lenient parser is not a browser validator."""
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.starts = []
        self.ends = []
        self.data = []
        self.comments = []

    def handle_starttag(self, tag, attrs):
        self.starts.append((tag, attrs))

    def handle_endtag(self, tag):
        self.ends.append(tag)

    def handle_data(self, data):
        self.data.append(data)

    def handle_comment(self, data):
        self.comments.append(data)


def observe(markup):
    parser = Observation()
    parser.feed(markup)
    parser.close()
    return {'starts': parser.starts, 'ends': parser.ends,
            'data': ''.join(parser.data), 'comments': parser.comments}


def expected_model(label, context):
    """Literal grammar expectation, independent of escaping and parser calls."""
    if context == 'text':
        return {'starts': [('p', [])], 'ends': ['p'], 'data': label, 'comments': []}
    return {'starts': [('div', [('title', label)])], 'ends': ['div'],
            'data': '', 'comments': []}


def numeric_reference(label, context):
    """Trusted fixture serializer: decimal references, no html.escape call."""
    special = '&<>' if context == 'text' else '&<>"\''
    encoded = ''.join('&#' + str(ord(char)) + ';' if char in special else char
                      for char in label)
    if context == 'text':
        return '<p>' + encoded + '</p>'
    return '<div title="' + encoded + '"></div>'


def fixed_lexical_shape(markup, context):
    """Own exact template/entity alphabet check, not general HTML validation."""
    prefix, suffix = ('<p>', '</p>') if context == 'text' else ('<div title="', '"></div>')
    if not markup.startswith(prefix) or not markup.endswith(suffix):
        return False
    encoded = markup[len(prefix):-len(suffix)]
    residual = re.sub(r'&(?:amp|lt|gt|quot|#x27);', '', encoded)
    forbidden = '&<>' if context == 'text' else '&<>"\''
    return not any(char in residual for char in forbidden)


def raises(call, error):
    try:
        call()
    except error:
        return True
    return False


def run_example():
    fixture = 'A & <B> "R" \'S\''
    text_result = render_label(fixture, 'text')
    title_result = render_label(fixture, 'title')
    checks = {}
    checks['literal_two_template_hand_answers'] = (
        text_result == '<p>A &amp; &lt;B&gt; "R" \'S\'</p>'
        and title_result == '<div title="A &amp; &lt;B&gt; &quot;R&quot; &#x27;S&#x27;"></div>')
    checks['quotes_are_data_in_fixed_text_and_encoded_in_fixed_attribute'] = (
        '"R"' in text_result and '&quot;R&quot;' in title_result
        and observe(text_result) == expected_model(fixture, 'text')
        and observe(title_result) == expected_model(fixture, 'title'))

    boundary_label = 'A" data-note="B'
    wrong_title = '<div title="' + escape(boundary_label, quote=False) + '"></div>'
    wrong_observation = observe(wrong_title)
    checks['quote_false_is_not_the_fixed_double_quoted_attribute_contract'] = (
        wrong_observation['starts'] == [('div', [('title', 'A'), ('data-note', 'B')])]
        and observe(render_label(boundary_label, 'title')) == expected_model(boundary_label, 'title'))

    escaped_once = render_label('&', 'text')
    escaped_again = render_label(escape('&', quote=False), 'text')
    checks['reencoding_encoded_data_changes_single_decode_result'] = (
        escaped_once == '<p>&amp;</p>' and escaped_again == '<p>&amp;amp;</p>'
        and observe(escaped_once)['data'] == '&' and observe(escaped_again)['data'] == '&amp;')
    entity_spelling = '&lt;A&gt; &amp;'
    checks['entity_spelling_in_raw_input_remains_literal_data_after_one_render'] = all(
        observe(render_label(entity_spelling, context)) == expected_model(entity_spelling, context)
        for context in ('text', 'title'))
    checks['empty_labels_preserve_fixed_empty_template_models'] = all(
        observe(render_label('', context)) == expected_model('', context)
        for context in ('text', 'title'))
    persian = '\u062d\u0633\u06af\u0631 & \u0648\u0644\u062a'
    checks['declared_persian_letters_survive_as_data_without_transliteration'] = all(
        observe(render_label(persian, context)) == expected_model(persian, context)
        for context in ('text', 'title'))

    alphabet = ('A', ' ', '&', '<', '>', '"', "'", '\u062d')
    cases, all_observations, all_shapes, all_references = 0, True, True, True
    for size in range(5):
        for chars in itertools.product(alphabet, repeat=size):
            label = ''.join(chars)
            for context in ('text', 'title'):
                actual = render_label(label, context)
                model = expected_model(label, context)
                reference = numeric_reference(label, context)
                all_observations = all_observations and observe(actual) == model
                all_shapes = all_shapes and fixed_lexical_shape(actual, context)
                all_references = all_references and observe(reference) == model
                cases += 1
    checks['finite_literal_event_models_ONE_family'] = bool(all_observations)
    checks['independent_fixed_template_and_entity_alphabet_ONE_family'] = bool(all_shapes)
    checks['decimal_reference_encoding_same_data_not_same_spelling_ONE_family'] = bool(all_references)
    checks['different_entity_spelling_is_not_a_different_label'] = (
        render_label('<&', 'text') != numeric_reference('<&', 'text')
        and observe(render_label('<&', 'text')) == observe(numeric_reference('<&', 'text')))

    class StringChild(str):
        def __len__(self):
            raise AssertionError('Unexpected subclass hook')

    bad_labels = (None, True, b'A', ['A'], StringChild('A'))
    bad_characters = ('A\nB', '\x00', '\ud800', '\U0001f642', '\u200c', '\u202e')
    checks['strict_types_character_profile_length_and_context_allowlist'] = (
        all(raises(lambda value=value: render_label(value, 'text'), TypeError) for value in bad_labels)
        and all(raises(lambda value=value: render_label(value, 'title'), ValueError) for value in bad_characters)
        and raises(lambda: render_label('A' * 49, 'text'), ValueError)
        and all(observe(render_label('A' * 48, context)) == expected_model('A' * 48, context)
                for context in ('text', 'title'))
        and all(raises(lambda context=context: render_label('A', context), ValueError)
                for context in ('url', 'script', 'style', 'onclick', 'raw', '', 'TEXT'))
        and all(raises(lambda context=context: render_label('A', context), TypeError)
                for context in (None, True, StringChild('text'))))
    assert all(type(value) is bool and value for value in checks.values()), checks
    return {'python': platform.python_version(), 'implementation': platform.python_implementation(),
            'scope': 'two own fixed templates only; HTMLParser callbacks, not browser or sanitizer',
            'profile': {'label': 'exact str0..48 printable ASCII plus declared Persian letters',
                        'context': ['text', 'title'], 'templates_fixed': True},
            'fixture_label': fixture, 'rendered_text': text_result, 'rendered_title': title_result,
            'decoded_text_observation': observe(text_result),
            'decoded_title_observation': observe(title_result),
            'wrong_quote_false_attribute_observation': wrong_observation,
            'double_encoding': {'once_markup': escaped_once, 'again_markup': escaped_again,
                                'once_decoded_data': observe(escaped_once)['data'],
                                'again_decoded_data': observe(escaped_again)['data']},
            'finite_context_cases_inside_each_ONE_family': cases,
            'browser_network_Javascript_sanitizer_or_security_product_executed': False,
            'checks': checks}


if __name__ == '__main__':
    print(json.dumps(run_example(), indent=2, ensure_ascii=True, allow_nan=False))

HTMLParser پایتون مشاهده‌گرِ callbackهاست. برای هر خروجی، نام و ویژگی‌های تگِ آغاز، تگِ پایان، متنِ حاصل و توضیح‌ها را جمع می‌کنیم و با مدلِ موردانتظارِ نوشته‌شده از قرارداد مقایسه می‌کنیم. مدلِ انتظار، خودِ تابعِ فراردهی یا parser را فراخوانی نمی‌کند. این parser ورودیِ معیوب را هم می‌خواند و تطابقِ همهٔ تگ‌ها را کنترل نمی‌کند؛ بنابراین مشاهدهٔ موردانتظار، آزمونِ محدود است و تأییدِ رفتارِ مرورگر نیست.

مرجعِ نوشتاریِ مستقل به‌جایِ html.escape برای نویسه‌های خاص، ارجاعِ عددیِ ده‌دهی می‌سازد. املایِ آن با خروجیِ اصلی فرق می‌کند، ولی پس از خواندن باید همان مدلِ داده را بدهد. دو مسیر از یک parser مشاهده‌گر استفاده می‌کنند؛ این مقایسه به‌تنهایی خطای مشترکِ آن را رد نمی‌کند. بررسیِ دیگری، پوستهٔ ثابتِ قالب و الفبایِ entityهای تولیدشده را مستقیم می‌سنجد. برای الفبایِ هشت‌عضوی و طول‌های صفر تا چهار، ۹۳۶۲ حالتِ زمینه بررسی شده است؛ این حالت‌ها داخلِ سه خانوادهٔ جدا بررسی می‌شوند، نه هزاران گروهِ نام‌دار یا آزمونِ همهٔ وب.

{
  "python": "3.14.5",
  "implementation": "CPython",
  "scope": "two own fixed templates only; HTMLParser callbacks, not browser or sanitizer",
  "profile": {
    "label": "exact str0..48 printable ASCII plus declared Persian letters",
    "context": [
      "text",
      "title"
    ],
    "templates_fixed": true
  },
  "fixture_label": "A & <B> \"R\" 'S'",
  "rendered_text": "<p>A &amp; &lt;B&gt; \"R\" 'S'</p>",
  "rendered_title": "<div title=\"A &amp; &lt;B&gt; &quot;R&quot; &#x27;S&#x27;\"></div>",
  "decoded_text_observation": {
    "starts": [
      [
        "p",
        []
      ]
    ],
    "ends": [
      "p"
    ],
    "data": "A & <B> \"R\" 'S'",
    "comments": []
  },
  "decoded_title_observation": {
    "starts": [
      [
        "div",
        [
          [
            "title",
            "A & <B> \"R\" 'S'"
          ]
        ]
      ]
    ],
    "ends": [
      "div"
    ],
    "data": "",
    "comments": []
  },
  "wrong_quote_false_attribute_observation": {
    "starts": [
      [
        "div",
        [
          [
            "title",
            "A"
          ],
          [
            "data-note",
            "B"
          ]
        ]
      ]
    ],
    "ends": [
      "div"
    ],
    "data": "",
    "comments": []
  },
  "double_encoding": {
    "once_markup": "<p>&amp;</p>",
    "again_markup": "<p>&amp;amp;</p>",
    "once_decoded_data": "&",
    "again_decoded_data": "&amp;"
  },
  "finite_context_cases_inside_each_ONE_family": 9362,
  "browser_network_Javascript_sanitizer_or_security_product_executed": false,
  "checks": {
    "literal_two_template_hand_answers": true,
    "quotes_are_data_in_fixed_text_and_encoded_in_fixed_attribute": true,
    "quote_false_is_not_the_fixed_double_quoted_attribute_contract": true,
    "reencoding_encoded_data_changes_single_decode_result": true,
    "entity_spelling_in_raw_input_remains_literal_data_after_one_render": true,
    "empty_labels_preserve_fixed_empty_template_models": true,
    "declared_persian_letters_survive_as_data_without_transliteration": true,
    "finite_literal_event_models_ONE_family": true,
    "independent_fixed_template_and_entity_alphabet_ONE_family": true,
    "decimal_reference_encoding_same_data_not_same_spelling_ONE_family": true,
    "different_entity_spelling_is_not_a_different_label": true,
    "strict_types_character_profile_length_and_context_allowlist": true
  }
}

دوازده گروه گذشتند؛ تنها یک اجرای کاملِ نویسنده ثبت شد و stderr خالی بود. مستنداتِ پایتونِ خوانده‌شده نسخهٔ 3.14.8 دارند و از اجرای 3.14.5 جدا ثبت شده‌اند. منابع در ۲۸ مهر ۱۴۰۵ خوانده شدند؛ تاریخِ انتشارِ اولیهٔ صفحه‌ها معلوم نیست و تاریخِ به‌روزرسانی یا دسترسی را جایِ آن نمی‌گذاریم. هیچ خروجیِ این برنامه، گواهِ امنیتِ کاملِ یک محصول یا جایگزینِ بررسیِ مسیرهای واقعیِ داده نیست.

۵. چهار پرسش دربارهٔ زمینه و محدودیتِ آزمون

آزمونِ قالبِ محدود، مرورگر یا پاک‌سازِ عمومی نیست
تصویر مفهومی: آزمونِ قالبِ محدود، مرورگر یا پاک‌سازِ عمومی نیست؛ بدون مقیاس عددی و مستقل از نتایج اجرای کد.

پرسش اول: چرا یک داده، دو خروجیِ نوشتاری دارد؟ پاسخ: جایگاهِ متن با مقدارِ ویژگی متفاوت است. در قالبِ ویژگی باید نقل‌قولِ داده نیز فراردهی شود؛ انتظارِ مشترک، حفظِ داده و ساختارِ ثابت است، نه برابریِ رشته‌های خروجی.

پرسش دوم: خروجی را برای اطمینان دوبار فراردهی کنیم؟ پاسخ: این کار می‌تواند املایِ entity را به دادهٔ نمایشی تبدیل کند. باید مرزِ ورودیِ خام و محلِ رندر روشن باشد؛ تعدادِ بیشترِ مراحل به‌تنهایی قراردادِ بهتری نمی‌سازد.

پرسش سوم: با همین تابع می‌توان HTML نوشته‌شدهٔ کاربر را مجاز کرد؟ پاسخ: خیر؛ این تابع، متن را داده نگه می‌دارد و ابزارِ نگه‌داشتنِ تگ‌های مجاز نیست. پاک‌سازیِ HTML و مدیریتِ محتوایِ قابلِ اجرا مسئلهٔ جداگانه‌ای است و اینجا پیاده نشده است.

پرسش چهارم: عبورِ همهٔ حالت‌ها یعنی هیچ XSS ممکن نیست؟ پاسخ: خیر؛ فقط دو قالب، دامنهٔ نویسه و مدلِ مشاهدهٔ اعلام‌شده سنجیده شدند. نشانی‌ها، رویدادها، چارچوب، DOM زنده، تغییرِ بعدیِ خروجی و مسیرهای دیگرِ برنامه بیرونِ شواهدِ این آزمایش‌اند.

منابع و مطالعهٔ بیشتر

بررسی منابع: ۲۸ مهر ۱۴۰۵. تحلیل‌ها و پیشنهادهای اجرایی، جمع‌بندی نویسنده‌اند.