Skip to content

Lesson 4 — Structural safety: how XSS is prevented by design

The XSS problem

Cross-site scripting (XSS) is one of the most common web vulnerabilities. It happens when user-supplied content is rendered as raw HTML, allowing an attacker to inject <script> tags or event handlers.

In ERB, the default changed in Rails 3: strings are now HTML-escaped unless you call html_safe or raw. But that escape hatch exists, and developers use it — sometimes correctly, sometimes not. The vulnerability surface is always present.

Phlex takes a different approach. It makes XSS structurally impossible — not through escaping as an afterthought, but through the design of the API itself.

How Phlex prevents XSS

In Phlex, text content is always escaped. There is no opt-out at the point of rendering text. If you pass user data as text content, it is escaped. Full stop.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
require "phlex"
require "date"

class SearchResult < Phlex::HTML
  def initialize(query)
    @query = query
  end

  def view_template
    p { "Results for: #{@query}" }
  end
end

# An attacker tries to inject a script tag
malicious_input = '<script>alert("XSS")</script>'
puts SearchResult.new(malicious_input).call

Output:

1
<p>Results for: &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;</p>

The <script> tag is rendered as literal text, not as HTML. The browser displays it as text. The script never executes.

Attribute values are also escaped

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
require "phlex"
require "date"

class LinkComponent < Phlex::HTML
  def initialize(href:, label:)
    @href  = href
    @label = label
  end

  def view_template
    a(href: @href) { @label }
  end
end

malicious_href  = '" onclick="alert(\'XSS\')'
malicious_label = '<img src=x onerror=alert(1)>'

puts LinkComponent.new(href: malicious_href, label: malicious_label).call

Output:

1
2
3
<a href="&quot; onclick=&quot;alert('XSS')">
  &lt;img src=x onerror=alert(1)&gt;
</a>

Both the attribute value and the text content are escaped. Neither attack vector works.

What if you genuinely need raw HTML?

Sometimes you have HTML content that is safe — for example, rendered markdown from a trusted library. Phlex provides raw for this, but it requires you to explicitly mark the content as safe using safe:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
require "phlex"
require "cgi"

class MarkdownRenderer < Phlex::HTML
  def initialize(html_content)
    @html_content = html_content
  end

  def view_template
    # safe() marks the string as trusted — only use with content
    # you have already sanitised or generated yourself
    div { raw safe(@html_content) }
  end
end

# This would be the output of a markdown library like Redcarpet or Kramdown
trusted_html = "<p>This is <strong>trusted</strong> HTML from our markdown renderer.</p>"
puts MarkdownRenderer.new(trusted_html).call

Output:

1
<div><p>This is <strong>trusted</strong> HTML from our markdown renderer.</p></div>

The key constraint: raw only accepts content that has been wrapped with safe. If you try to pass a plain string to raw, Phlex raises an error. You can’t accidentally bypass the safety mechanism — you have to deliberately and explicitly mark content as safe.

Compare this to ERB’s <%= raw some_string %> which accepts any string with no ceremony. Phlex requires an intentional declaration.

The structural safety model

The Phlex safety model is:

  1. Text content: always escaped. No exceptions, no opt-out.
  2. Attribute values: always escaped. No exceptions, no opt-out.
  3. Raw HTML: only accepted if wrapped in safe(), which is an explicit developer declaration of trust.
  4. Attribute names: validated against a known-good list. You cannot inject arbitrary attribute names from user input.

This is “structural safety” — the safe path is the default path, and the unsafe path requires deliberate effort.

A practical demonstration: safe vs unsafe

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
require "phlex"
require "date"

class CommentDisplay < Phlex::HTML
  # This is a real-world scenario: displaying user comments
  # Some comments might contain HTML in the text

  def initialize(author:, body:)
    @author = author
    @body   = body
  end

  def view_template
    div do
      strong { @author }   # Escaped — safe no matter what user puts here
      p     { @body }      # Escaped — that <script> tag will be harmless text
    end
  end
end

# Attacker's comment
puts CommentDisplay.new(
  author: "Legit User<script>evil()</script>",
  body:   "<img src=x onerror='stealCookies()'>"
).call

Output:

1
2
3
4
<div>
  <strong>Legit User&lt;script&gt;evil()&lt;/script&gt;</strong>
  <p>&lt;img src=x onerror='stealCookies()'&gt;</p>
</div>

The attack is defused at the rendering layer, automatically, without any developer action.

Exercise

Create 04_exercise.rb. Build a UserProfileComponent that displays a user’s name, bio, and website URL — all supplied as strings. Demonstrate that injecting <script> tags into any field produces harmless escaped output. Then add a second variant that accepts a bio_html: parameter — pre-rendered, trusted HTML from a markdown processor — and renders it using raw(safe(...)).


Solution to Exercise 04
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
require "phlex"
require "date"

class UserProfileComponent < Phlex::HTML
  def initialize(name:, bio:, website_url:)
    @name        = name
    @bio         = bio
    @website_url = website_url
  end

  def view_template
    article do
      h2 { @name }
      p  { @bio }
      p  { a(href: @website_url) { @website_url } }
    end
  end
end

class UserProfileWithHtmlBio < Phlex::HTML
  def initialize(name:, bio_html:, website_url:)
    @name        = name
    @bio_html    = bio_html
    @website_url = website_url
  end

  def view_template
    article do
      h2 { @name }
      div { raw safe(@bio_html) }
      p  { a(href: @website_url) { @website_url } }
    end
  end
end

# --- Demonstrate XSS safety ---

puts "=== Standard component — all input escaped ==="
puts UserProfileComponent.new(
  name:        "Alice<script>alert('xss')</script>",
  bio:         "I love <em>Ruby</em> & open source.",
  website_url: "https://alice.dev\" onclick=\"alert(1)"
).call

puts
puts "=== Variant with trusted HTML bio ==="

# Simulated output from a markdown processor — trusted content
trusted_bio_html = "<p>I love <em>Ruby</em> and <strong>open source</strong>.</p>"

puts UserProfileWithHtmlBio.new(
  name:        "Alice",
  bio_html:    trusted_bio_html,
  website_url: "https://alice.dev"
).call