SupportWriter space ↗
← Back to the journal
Application Security

Cross-Site Scripting: Follow Untrusted Data to the Browser

Review browser output contexts, rich text, and DOM updates to prevent cross-site scripting without breaking legitimate content.

DPutu Adi Guna Permana · 21 Oct 2026 · 6 min read

Cross-site scripting, or XSS, occurs when an application allows untrusted content to become executable browser content. The most productive review follows data from its entry point to the exact place where the browser uses it.

Start with a realistic content flow

Imagine a community website with profile names, comments, and a moderator dashboard. A comment can travel through several views before publication. Map each rendering location, including previews, notification pages, and moderation screens. A field that looks harmless in one template may reach a different browser context elsewhere.

Use unique plain-text markers in synthetic records to discover these paths. A marker proves that data reaches a view; it does not, by itself, prove script execution.

Identify the output context

Text inside an HTML element, an HTML attribute, a URL, and JavaScript source each have different interpretation rules. Use the framework's escaped text output for ordinary text. Avoid placing untrusted strings inside executable script blocks or event-handler attributes.

For browser-side updates, prefer APIs that assign text content when markup is unnecessary. Review code that builds HTML strings or assigns raw HTML. Convenience helpers can hide these operations several layers away from the original input.

Treat rich text as a separate feature

If users genuinely need formatted HTML, define the allowed elements, attributes, and URL schemes. Apply a maintained HTML sanitizer configured for that policy. Sanitization is different from encoding: encoding displays markup as text, while sanitization permits a controlled subset of markup.

A Content Security Policy can reduce exposure, but it should accompany correct output handling. HttpOnly cookies limit direct JavaScript access to a cookie; they do not prevent a script from issuing actions through the victim's browser session.

Validate the fix in every destination

Use harmless, explicitly approved browser demonstrations in a controlled environment if execution confirmation is needed. Keep the demonstration isolated to test accounts and avoid collecting tokens or third-party requests.

After remediation, check that normal punctuation, multilingual names, and legitimate formatting still render correctly. Revisit all known destinations, especially staff interfaces that display user-generated content.

The OWASP Cross Site Scripting Prevention Cheat Sheet explains output encoding, sanitization, and unsafe browser contexts.

Reproducible lab: encode text without losing its meaning

Revision — 28 October 2026: this section adds an output-encoding example executed with Python 3.9.6. It uses only standard-library modules and performs no network requests. The checks inspect generated HTML; they are not a browser execution test.

Consider a profile card that needs to display a name and a literal documentation snippet. Both are plain text. The feature does not need to allow users to supply HTML. The simplest boundary is therefore to encode both values exactly when inserting them into the fixed HTML template.

Save the complete example below as output_lab.py and run python3 output_lab.py:

from html import escape, unescape
from html.parser import HTMLParser


class ElementCollector(HTMLParser):
    def __init__(self):
        super().__init__()
        self.tags = []

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


def profile_card(name, note):
    return (
        '<section class="profile">'
        f'<h2>{escape(name, quote=True)}</h2>'
        f'<p>{escape(note, quote=True)}</p>'
        '</section>'
    )


name = 'Dana & Lee'
note = '<strong>Sample markup</strong>'
rendered = profile_card(name, note)
assert 'Dana &amp; Lee' in rendered
assert '&lt;strong&gt;Sample markup&lt;/strong&gt;' in rendered

parser = ElementCollector()
parser.feed(rendered)
assert parser.tags == ['section', 'h2', 'p']
assert unescape(escape(note, quote=True)) == note

print(rendered)
print("4 output checks passed")

Observed output:

<section class="profile"><h2>Dana &amp; Lee</h2><p>&lt;strong&gt;Sample markup&lt;/strong&gt;</p></section>
4 output checks passed

What the assertions establish

The first check preserves a legitimate ampersand as displayable text. The second checks that angle brackets are encoded. The third confirms that the generated markup contains only the fixed template's elements; the user's strong text does not create an extra element. The fourth checks that encoding has preserved the original text rather than deleting its punctuation.

For comparison, replace the encoded note with raw note inside this local example and rerun it. The expected-output and element checks should fail. The example intentionally uses harmless formatting text, so no script execution is needed to demonstrate that raw insertion changes the document structure.

Why this helper has a narrow contract

profile_card accepts text and inserts it into HTML element content. It is not a universal sanitizer. Do not reuse an HTML-escaping operation as the security decision for a destination URL, CSS expression, JavaScript string, or browser DOM operation. A string may be encoded correctly for an attribute and still select a dangerous URL scheme.

For a real link feature, define the accepted URL forms separately and use appropriate parsing and scheme checks. If a feature needs formatted user content, use a maintained HTML sanitizer with an explicit policy rather than expanding this example into an ad hoc regular-expression filter.

Do not decode the output again immediately before displaying it. Encoding followed by an unescape operation at the rendering boundary would restore markup interpretation. In the lab, unescape is used only to assert information preservation, never to construct the returned card.

Translate this into framework and browser checks

In your server framework, use its ordinary escaped output for names and comments. Inspect any raw-output helpers separately. In client-side code, assign plain text with a text API rather than concatenating it into HTML.

For regression coverage, submit a synthetic comment containing punctuation and visible markup-like text. Check the public page, preview, moderation queue, and notification views. A staff page that renders the same field differently needs its own assertion. If a rich-text feature is present, verify both forbidden structures and the legitimate formatting that should remain available.

This lab establishes a small encoding contract. It does not test a browser's DOM behavior, every character encoding, client-side templates, a Content Security Policy, or a sanitizer's full policy. Those boundaries require additional application-specific checks.

Python documents the helper's behavior in html.escape.

Comments 0

Share your questions, experiences, or feedback about this article.

No comments yet. Be the first to share your feedback.

← Explore more notes