Skip to content

CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') - Perl

Overview

Cross-Site Scripting (CWE-79) occurs when untrusted data is included in web pages without proper encoding. Attackers inject scripts that execute in victim browsers, leading to session hijacking or credential theft. Perl applications must use context-appropriate encoding functions like escapeHTML() from CGI.pm, encode_entities() from HTML::Entities, or framework-specific auto-escaping features. Unlike CWE-80, which covers basic XSS, CWE-79 encompasses reflected, stored, and DOM-based XSS attacks across all contexts (HTML, JavaScript, URL, CSS, JSON).

Primary Defence: Encode every user-controlled value for the context it lands in. In HTML that means HTML::Entities::encode_entities(), CGI.pm's escapeHTML(), an explicit template filter (Template Toolkit's | html filter or [% FILTER html %]), or verified framework auto-escaping such as Mojolicious's %= tag. JavaScript, URL, and CSS contexts each need their own encoding. Set Content-Type and X-Content-Type-Options: nosniff headers, and use Content Security Policy (CSP) to reduce the impact of an injection that gets through.

Common Vulnerable Patterns

Direct Variable Interpolation

# VULNERABLE - No escaping

use CGI;
my $q = CGI->new;
my $name = $q->param('name');

print "<h1>Welcome, $name</h1>";  # XSS vulnerability!

# Attack: name=<script>alert('XSS')</script>

Why this is vulnerable: Perl interpolates the variable into the double-quoted string verbatim - there is no context in which the language treats $name as text to be encoded rather than characters to be substituted. Whatever the parameter held becomes markup at that position.

HTML::Entities::encode_entities() is the current recommendation for this; CGI::escapeHTML() does the same job for code already using CGI.pm. Both encode for element content, which is the context here - an attribute value or a URL needs a different encoder, which is why reaching for a template engine with automatic contextual escaping beats calling one by hand at every output.

Unescaped CGI Param() Output

# VULNERABLE - Direct param usage

use CGI;
my $q = CGI->new;

print $q->header;
print "<html><body>";
print "<p>Search results for: " . $q->param('query') . "</p>";  # VULNERABLE
print "</body></html>";

Why this is vulnerable: param() returns the request value unchanged; the module parses the request, it does not sanitise it.

Worth checking before triaging: CGI.pm was removed from the Perl core in 5.22 and is now maintained on CPAN as a legacy distribution, so code reaching for it is usually old enough that other assumptions on the page deserve a look too. There is also a Perl-specific trap in this line - param() in list context returns all values for the key, so a request sending the parameter twice interpolates both, which is enough to defeat validation that inspected only the first.

Template Toolkit with EVAL_PERL Enabled

# VULNERABLE - EVAL_PERL allows code execution

my $tt = Template->new({
    EVAL_PERL => 1,  # DANGEROUS!
});

$tt->process('template.html', { user_data => $user_input });

# Template can execute arbitrary Perl:

# [% PERL %] system($user_data); [% END %]

Why this is vulnerable: This one is not cross-site scripting despite its position on the page. EVAL_PERL permits [% PERL %] blocks in templates, so anyone who can influence template content executes Perl in the application's process - that is remote code execution, and it is CWE-95.

It matters here because the two are reached the same way. A template assembled from user input, or a template path chosen by the request, turns an output-encoding problem into an execution one. EVAL_PERL defaults to off; there is no configuration in which enabling it is safe alongside untrusted template content, so the fix is to leave it off rather than to filter what reaches it.

Unsafe Mason Filters

# VULNERABLE - Bypassing configured Mason escaping

<div><% $user_input | n %></div>  # No escaping - XSS!

# Attack: user_input = "<script>alert('XSS')</script>"

Why this is vulnerable: | n means "apply no escape flags", and what it cancels is whatever default_escape_flags was configured to. That parameter is empty by default in HTML::Mason, so the baseline is worth stating plainly: Mason does not escape <% %> output unless the application asked it to. An interpolation with no filter at all is exactly as exposed as this one.

So there are two findings hiding behind this line. If the application sets default_escape_flags => 'h', this | n is a control switched off at the one place it mattered, and the question is why - usually because the value contains markup the page needs to render, in which case the answer is sanitisation with an HTML allowlist parser before storage rather than escaping suppressed at output. If default_escape_flags is unset, the | n is doing nothing and the whole component is unescaped, so fixing this line fixes almost none of the problem. Check the configuration before scoping the work, set default_escape_flags => 'h' so the default is safe, and use | h where a component overrides a different global.

Manual HTML Construction Without Encoding

# VULNERABLE - String concatenation

my $html = "<div class='user'>" . $user_bio . "</div>";
print $html;  # No escaping!

Why this is vulnerable: Concatenation puts the value into the document unchanged, so the bio decides the markup.

The single quotes around class='user' deserve attention because they narrow what an attacker needs. With single-quoted attributes a lone ' closes the value and the rest of the input becomes new attributes - ' onmouseover='alert(1) needs no < or > at all, so a filter that strips angle brackets and looks sufficient stops nothing here. That is the general argument against hand-built HTML: the encoding depends on quoting the author chose elsewhere in the string, and a template engine settles both together.

Secure Patterns

CGI.pm with escapeHTML()

# SECURE - Explicit HTML escaping

use CGI qw(:standard escapeHTML);

my $q = CGI->new;
my $user_comment = $q->param('comment');
my $safe_comment = escapeHTML($user_comment);

print $q->header;
print "<p>Comment: $safe_comment</p>";

# Auto-escaping CGI functions (when using CGI.pm 1.57+):
print textfield(-name => 'username', -value => $user_value);
print textarea(-name => 'bio', -value => $user_bio);
print popup_menu(-name => 'role', -values => \@roles);
print checkbox(-name => 'agree', -value => 'yes');

Why this works:

CGI.pm's escapeHTML() replaces the HTML metacharacters (<, >, &, ") with their entity equivalents (&lt;, &gt;, &amp;, &quot;), so the browser displays them as literal text rather than parsing them as markup. The substitution happens in your Perl code before the HTTP response is generated: an attacker submitting <script>alert('xss')</script> gets &lt;script&gt;alert('xss')&lt;/script&gt; on the page.

Encoding double quotes ("&quot;) prevents quote-based injection in HTML attributes, but only where the attribute value is quoted - use qq{<div title="$safe">} rather than <div title=$safe>. Since CGI.pm version 1.57+, auto-escaping is enabled by default for CGI form generation functions like textfield() and popup_menu(), but escapeHTML() remains necessary when manually building HTML output. This encoding is specifically designed for HTML contexts - you still need different encoding for JavaScript (JSON encoding), URLs (escape() function), or CSS contexts.

HTML::Entities for HTML Contexts

# SECURE - Context-aware encoding

use HTML::Entities qw(encode_entities);

my $safe_html = encode_entities($user_data);
print "<div>$safe_html</div>";

# For attributes

my $safe_attr = encode_entities($attr_value, '<>&"\'');
print qq{<input value="$safe_attr">};

Why this works:

HTML::Entities' encode_entities() encodes more broadly than CGI.pm's escapeHTML(): not just the critical HTML metacharacters, but every character that has an HTML entity equivalent. Called without a second parameter, encode_entities($text) encodes control characters, high-bit characters, and HTML-significant characters (<, >, &, ", ') into named or numeric entities, which suits international content containing accented characters, mathematical symbols, or other Unicode. Called with a second parameter it encodes only the characters you name - encode_entities($text, '<>&"\'') covers the XSS-relevant ones, does less work, and leaves the HTML source readable. Either way, <script>alert('xss')</script> becomes &lt;script&gt;alert('xss')&lt;/script&gt;, which browsers display as text instead of executing.

For HTML attributes, encode both quote characters and wrap the attribute value in quotes. Like the other HTML encoders here, this protects HTML contexts only - JavaScript contexts need JSON encoding, and URL contexts need percent-encoding with URI::Escape.

Template Toolkit with Explicit Escaping

# SECURE - Safe Template Toolkit configuration and explicit escaping

use Template;

my $tt = Template->new({
    EVAL_PERL    => 0,     # Never enable this
    INTERPOLATE  => 0,     # Disable Perl interpolation
    POST_CHOMP   => 1,
});

# Template Toolkit does not HTML-escape plain variables by default.
# Escape untrusted values in the template for the context where they appear.

$tt->process('page.html', {
    username => $user_input,
    comment  => $user_comment,
});

# Template (page.html):
# <div>[% username | html %]</div>
# <p>[% comment | html %]</p>
#
# URL encoding:
# <a href="/user?id=[% user_id | url %]">Profile</a>

Why this works:

Template Toolkit (TT) escapes output only when you explicitly apply an escaping filter or configure an application-level autoescape mechanism. A raw directive such as [% username %] inserts the value without HTML entity encoding and is unsafe for untrusted data in HTML output. Applying | html converts dangerous HTML characters into entity representations during template processing, so injected markup is displayed as text instead of being interpreted by the browser. The EVAL_PERL => 0 setting prevents template code from executing arbitrary Perl and bypassing application security controls, and INTERPOLATE => 0 avoids Perl variable interpolation in template text; these are code-execution protections, not output-encoding controls, and EVAL_PERL has no safe production use. For non-HTML contexts, use the appropriate TT filter such as | url for URL parameters and a JavaScript-safe JSON encoding approach for script data.

Context-Specific Encoding

HTML Attribute Context

# SECURE - HTML attribute encoding
use CGI qw(escapeHTML);

my $safe_attr = escapeHTML($user_value);
print qq{<input type="text" value="$safe_attr">};

# Always wrap attributes in quotes for full protection
print qq{<div title="$safe_attr" class="user-input">};

Why this works:

HTML attribute contexts require careful encoding because attackers can break out of attributes using quotes. escapeHTML() encodes double quotes (" -> &quot;), which is why the example uses a double-quoted attribute. Single-quote handling depends on the CGI.pm version: before 4.11, escapeHTML() only converted ' to &#39; when the object's charset was set to ISO-8859-1 or Windows-1252, leaving it unescaped under other charsets such as UTF-8; CGI.pm 4.11 and later escape ' unconditionally regardless of charset. If a codebase might run on an older CGI.pm, use encode_entities($value, '<>&"\'') or standardize on double quotes before relying on the helper. Always wrap attribute values in quotes - unquoted attributes like <div class=$value> are vulnerable even with encoding because attackers can inject space-separated attributes. The qq{} operator in Perl allows you to use double quotes inside the string without escaping, making the code more readable. This encoding is specifically for HTML attributes - don't use it for JavaScript or URL contexts.

JavaScript Context

# SECURE - Carry the JSON in a data attribute, out of the script context
use JSON::XS;
use HTML::Entities qw(encode_entities);

my $search_term = $q->param('search');
my $json = JSON::XS->new->encode({ term => $search_term });
my $attr = encode_entities($json, '<>&"\'');

print qq{
<div id="search-data" data-search="$attr"></div>
<script>
    var searchData = JSON.parse(
        document.getElementById('search-data').dataset.search
    );
    console.log(searchData.term);
</script>
};

Why this works: JSON encoding alone is not enough to put a value inside a <script> element, and the difference matters because the failure is silent. No JSON encoder escapes < or /, so a search_term of </script><script>alert(1)</script> produces var searchData = {"term":"</script><script>alert(1)</script>"}; - and the browser's HTML parser ends the script element at that first </script> before JavaScript ever parses the string. Everything after it is markup, and it runs. The JSON is valid throughout; the injection happens a layer above it.

Keeping the data out of the script context removes the problem rather than encoding around it. Inside a quoted attribute, encode_entities is the right tool and </script> is inert, because an attribute value cannot close an element. JSON.parse on the other side returns the same structure the Perl code encoded, and dataset reads the attribute after the browser has already decoded the entities - so there is no second decoding step to add, and adding one would reintroduce the bug.

Do not write a custom regex-based escape function for JavaScript string contexts - it is easy to miss a character (control characters, </script> sequences, Unicode line separators) and end up with an incomplete escaper. If a value must go directly into a script block rather than through the pattern above, the characters that have to become unicode escapes are <, > and & - which is what PHP's JSON_HEX_TAG and JSON_HEX_AMP flags and Rails' json_escape do internally. Perl's JSON modules expose no equivalent option, which is why the data attribute is the shorter path to a correct answer here.

One clarification on the HTML-encoding advice above, because this pattern uses both tools. Entity encoding is the wrong tool inside a script block: a JavaScript interpreter does not decode entities, so &lt; stays four literal characters there and legitimate data is corrupted. It is the right tool for the attribute this pattern uses, because that value is read by the HTML parser, which does decode them.

URL Context

# SECURE - URL parameter encoding
use CGI qw(escape);

my $safe_param = escape($user_input);
print "<a href='/search?q=$safe_param'>Search</a>";

# Or use URI::Escape for more control
use URI::Escape qw(uri_escape uri_escape_utf8);
my $encoded = uri_escape_utf8($param);
print "<a href='/results?query=$encoded'>Results</a>";

Why this works:

URL contexts require percent-encoding (URL encoding) which converts special characters into %XX hexadecimal format. CGI.pm's escape() function and URI::Escape's uri_escape() encode characters that have special meaning in URLs (&, =, ?, /, #, spaces, etc.) into percent-encoded equivalents. For example, hello world&delete=all becomes hello%20world%26delete%3Dall, preventing the &delete=all from being interpreted as a separate parameter. Use uri_escape_utf8() for Unicode strings to ensure proper UTF-8 encoding. URLs appearing inside HTML attributes need both URL encoding (to create a valid URL) and HTML attribute encoding (to prevent breaking out of the attribute), though URL encoding alone is common because percent-encoded characters are already safe in HTML attributes.

Mojolicious Framework (Auto-Escaping)

# SECURE - Mojolicious auto-escapes by default

use Mojolicious::Lite;

get '/profile' => sub {
    my $c = shift;
    my $bio = $c->param('bio');

    $c->render(template => 'profile', bio => $bio);
};

# Template (profile.html.ep):
# <div class="bio">
#     <%= $bio %>   <!-- Auto-escaped -->
# </div>
#
# Raw output (DANGEROUS - only for trusted content):
# <%== $trusted_html %>   <!-- NOT escaped -->

Why this works:

Mojolicious escapes automatically in its embedded Perl (EP) templates: every <%= $variable %> tag is HTML entity encoded during rendering, after your controller passes data via $c->render() but before the response is sent. <%= $bio %> converts <, >, &, " and ' into &lt;, &gt;, &amp;, &quot; and &#39;, so a <script>alert('xss')</script> submitted through a form parameter displays as text rather than executing.

Escaping is the default and bypassing it is explicit: single-equals <%= %> for all untrusted data, double-equals <%== %> only for pre-sanitized HTML from trusted sources. Because the dangerous form is the one that has to be opted into, it is easy to spot in code review. For non-HTML contexts, use Mojolicious helpers like url_for with proper encoding or Mojo::JSON for JavaScript contexts.

Mason Framework (Configured Escaping)

# SECURE - HTML::Mason escaping when configured
# Verify in httpd.conf or handler.pl:
# default_escape_flags => 'h'  # HTML escaping enabled

# In Mason component:
<div class="user-content">
    <% $user_input %>        <!-- Auto-escaped -->
    <% $user_comment | h %>  <!-- Explicitly escaped -->
</div>

# Raw output (DANGEROUS - only for trusted HTML)
<div><% $trusted_html | n %></div>  <!-- NOT escaped -->

Why this works:

HTML::Mason can HTML-encode component output when default_escape_flags => 'h' is configured. With that setting enabled, <% $user_input %> is escaped before insertion into the response. The | h filter explicitly applies HTML escaping and makes the security intention clear. The | n filter ("no escaping") bypasses all encoding and should only be used for pre-sanitized HTML from trusted sources - never use it with user input. Always verify your Mason configuration instead of assuming auto-escaping is enabled. For non-HTML contexts like JavaScript or URLs, use additional context-specific encoding beyond Mason's HTML escaping.

Framework-Specific Guidance

CGI.pm

use CGI qw(:standard);

# ALWAYS escape user input
my $q = CGI->new;
my $input = escapeHTML($q->param('input'));

# Use CGI functions for form elements (auto-escape)
print textfield(-name => 'username', -value => $user_value);
print popup_menu(-name => 'role', -values => \@roles);

Template Toolkit

# SECURE - Template Toolkit with EVAL_PERL disabled
my $tt = Template->new({
    EVAL_PERL => 0,  # CRITICAL: MUST be 0 for security
});

# Escape untrusted values explicitly:
# [% username | html %]
# [% user_id | url %]

Mason

# Verify in httpd.conf or handler.pl:
# default_escape_flags => 'h'  # HTML escaping enabled
# In components:
<% $user_input %>        <!-- Escaped only when default_escape_flags is enabled -->
<% $user_input | h %>    <!-- Explicitly escaped -->
<% $user_input | n %>    <!-- NOT escaped - AVOID -->

Catalyst

# SECURE - Catalyst with Template Toolkit
package MyApp::Controller::User;
use Moose;
use namespace::autoclean;

BEGIN { extends 'Catalyst::Controller'; }

sub profile : Local {
    my ($self, $c) = @_;

    my $bio = $c->request->param('bio');
    $c->stash(
        template => 'user/profile.tt',
        bio      => $bio,  # Escape in template
    );
}

# Template (user/profile.tt):
# <div>[% bio | html %]</div>

Dancer2

# SECURE - Dancer2 with Template Toolkit
use Dancer2;

get '/welcome' => sub {
    my $name = param('name');

    template 'welcome', {
        name => $name,  # Escape in template
    };
};

# Template (views/welcome.tt):
# <h1>Welcome, [% name | html %]!</h1>

Verify Encoding Functions

use Test::More tests => 4;
use HTML::Entities qw(encode_entities);

my $malicious = '<script>alert("XSS")</script>';
my $encoded = encode_entities($malicious);

unlike($encoded, qr/<script>/, 'Script tags are encoded');
like($encoded, qr/&lt;script&gt;/, 'Contains encoded entities');
unlike($encoded, qr/alert/, 'Alert function not in clear text');
ok(length($encoded) > length($malicious), 'Encoded string is longer');

Check Template Configuration

# Verify Template Toolkit settings

use Template;
use Data::Dumper;

my $tt = Template->new;
print Dumper($tt->{CONFIG});

# Verify EVAL_PERL is 0 or undefined
# Verify INTERPOLATE is 0

Content Security Policy Headers

CSP limits what an injected script can do; it does not stop the injection, so it belongs alongside output encoding rather than instead of it.

use CGI;
use Crypt::URandom qw(urandom);
use MIME::Base64 qw(encode_base64);

my $q = CGI->new;

# Fresh and unpredictable on every response. A literal such as
# 'nonce-random123' is not a nonce: it is published in the response, so an
# attacker reads it and puts it on their own injected <script>.
my $nonce = encode_base64(urandom(16), '');

print $q->header(
    -type => 'text/html',
    -charset => 'utf-8',
    -Content_Security_Policy => "default-src 'self'; " .
                                "script-src 'self' 'nonce-$nonce'; " .
                                "style-src 'self' 'unsafe-inline'; " .
                                "img-src 'self' https:;"
);

# Emit the same value on each inline script:
# print qq{<script nonce="$nonce">...</script>};

Why this works: Crypt::URandom reads the operating system CSPRNG, which is what a nonce needs - Perl's built-in rand() is predictable from previous output and unsuitable. The nonce has to be regenerated per response, because its whole value is that an attacker writing markup cannot guess it. The second argument to encode_base64 suppresses the line break it would otherwise append, which would corrupt the header.

Common Pitfalls

  • Assuming Template Toolkit auto-escapes by default. TT does not escape output unless you apply an explicit filter ([% var | html %]) or configure a global AUTO_FILTER; a page that filters some variables correctly can give false confidence that every [% var %] on the same template is safe.
  • Trusting that HTML::Mason escapes component output without checking configuration. <% $var %> is only escaped when default_escape_flags => 'h' is actually set in httpd.conf or the handler; without verifying that setting, a code review can wrongly conclude Mason output is safe by default.
  • Using escapeHTML() or encode_entities() for a value inserted into a JavaScript string. These functions encode <, >, &, and quotes for HTML, but they do not escape the backslashes or control characters needed to keep data inside a JS string literal, so a payload like '; alert(1); // still breaks out even though the HTML-significant characters are entity-encoded.
  • Hand-rolling a regex substitution to strip <script> tags or escape JavaScript strings instead of using HTML::Entities/CGI::escapeHTML for HTML or JSON::XS for JavaScript contexts. A custom pattern misses obfuscated tags, encoded payloads, and control characters that a maintained encoder already handles.

Additional Resources