Perl Security
Original work by ECC. This guide keeps the same goals and gives clear, safe defaults.
Use this skill to find and fix common security bugs in Perl code.
When to Use This Skill
Use this skill when you:
- Handle user input in Perl.
- Build a Perl web app.
- Review Perl code for security bugs.
- Work with user-given file paths.
- Run a system command from Perl.
- Write a DBI query.
- Handle passwords, cookies, or sessions.
Core Rules
Follow these rules first:
- Treat all outside data as unsafe.
- Check type, size, form, and allowed values.
- Use an allowlist when you can.
- Keep user data out of shell command strings.
- Use DBI placeholders for SQL values.
- Escape output for its exact use.
- Use safe file paths and file modes.
- Hide secret data from logs and errors.
- Use tested security modules for passwords and CSRF.
- Give each process and database user only the rights it needs.
Taint Mode
Taint mode tracks data from outside the program. It blocks some unsafe uses until the data is checked.
Enable it when you start Perl:
#!/usr/bin/perl -T
use v5.36;
$ENV{PATH} = '/usr/local/bin:/usr/bin:/bin';
delete @ENV{qw(IFS CDPATH ENV BASH_ENV)};
my $input = $ARGV[0];
my $form = <STDIN>;Taint mode must be set when Perl starts. You cannot turn it on later with use.
Taint mode helps, but it does not prove that data is safe. Your check must match the real use.
Check and Untaint Data
Capture only the part that is allowed:
use v5.36;
sub valid_username($input) {
defined $input or die "Username is required\n";
if ($input =~ /\A([A-Za-z0-9_]{3,30})\z/) {
return $1;
}
die "Username must use 3 to 30 letters, digits, or underscores\n";
}
sub valid_filename($input) {
defined $input or die "Filename is required\n";
if ($input =~ /\A([A-Za-z0-9][A-Za-z0-9._-]{0,99})\z/) {
my $name = $1;
die "Invalid filename\n" if $name eq '.' || $name eq '..';
return $name;
}
die "Invalid filename\n";
}Do not use a check that accepts all text:
sub bad_untaint($input) {
$input =~ /\A(.*)\z/s;
return $1;
}Input Checks
Check data at the point where it enters the app.
Check all of these when they apply:
- The value is present.
- The value has the right type.
- The value is not too long.
- The value is in the allowed set.
- The number is in a safe range.
- The text uses the expected form.
- The list has a safe item count.
Prefer Allowlists
use v5.36;
sub valid_sort_field($field) {
my %allowed = map { $_ => 1 } qw(name email created_at updated_at);
defined $field && $allowed{$field}
or die "Invalid sort field\n";
return $field;
}
sub valid_integer($input, $min, $max) {
defined $input or die "Number is required\n";
$input =~ /\A(-?\d{1,10})\z/ or die "Invalid number\n";
my $value = 0 + $1;
die "Number is out of range\n"
if $value < $min || $value > $max;
return $value;
}
sub valid_comment($text) {
defined $text or die "Comment is required\n";
my $size = length $text;
die "Comment is required\n" if $size == 0;
die "Comment is too long\n" if $size > 10_000;
return $text;
}Do not trust a small list of blocked marks. Attack text can use forms that the list misses.
For email addresses, use a well-tested email module when strict checks matter. A short regex may reject valid email or accept bad email.
Decode Before You Check
If input is URL encoded, JSON encoded, or sent in another form, decode it once and then check the result. Do not keep decoding it. Repeated decoding can turn safe-looking text into unsafe text.
Set request size limits before reading large bodies or uploads.
Safe Regular Expressions
A slow regex can let a small input use much CPU time.
Avoid nested repeats:
my $bad_one = qr/\A(a+)+\z/;
my $bad_two = qr/\A([A-Za-z]+)*\z/;Use a simple form:
my $good_one = qr/\Aa+\z/;
my $good_two = qr/\A[A-Za-z]+\z/;Also limit input size before a regex check:
sub valid_tag($text) {
defined $text or die "Tag is required\n";
die "Tag is too long\n" if length($text) > 50;
$text =~ /\A([A-Za-z0-9_-]+)\z/
or die "Invalid tag\n";
return $1;
}Do not build a regex from user text unless that is the goal. Use quotemeta or \Q...\E when the text must be read as plain text:
my $needle = quotemeta($user_text);
my $found = $body =~ /$needle/;An alarm may stop a slow match on systems that support it, but a simple regex and a size limit are better.
Safe File Use
Use Three-Argument open
use v5.36;
sub read_file($path) {
open my $fh, '<:encoding(UTF-8)', $path
or die "Cannot open file: $!\n";
local $/;
my $content = <$fh>;
close $fh or die "Cannot close file: $!\n";
return $content;
}Do not use two-argument open with user data:
open my $fh, $user_path;
open my $fh, "< $user_path";Keep Paths Under a Safe Folder
Path text checks alone do not stop links or path race bugs. Use a fixed base folder. Resolve existing paths and check the folder edge.
use v5.36;
use Cwd qw(realpath);
use File::Spec;
sub safe_existing_path($base_dir, $user_path) {
defined $user_path or die "Path is required\n";
die "NUL byte blocked\n" if $user_path =~ /\0/;
my $base = realpath($base_dir)
// die "Base folder does not exist\n";
my $full = realpath(File::Spec->catfile($base, $user_path))
// die "Path does not exist\n";
my $prefix = $base . '/';
die "Path leaves the safe folder\n"
unless $full eq $base || index($full, $prefix) == 0;
return $full;
}For a new file, its final path does not exist yet. Check the real parent folder, use a safe file name, and create the file in one step:
use v5.36;
use Fcntl qw(O_CREAT O_EXCL O_WRONLY);
sub create_private_file($path) {
sysopen my $fh, $path, O_WRONLY | O_CREAT | O_EXCL, 0600
or die "Cannot create file: $!\n";
return $fh;
}Also follow these rules:
- Use
File::Tempfor temp files. - Set private modes such as
0600when needed. - Use
flockwhen many jobs may write the same file. - Do not check a path and later open a different path string.
- Do not follow user-made links in a write folder.
- Do not use user input as an upload file name.
- Store uploads with a new random name.
- Set file size and file count limits.
- Check file content when its type matters. Do not trust the suffix.
Safe Process Use
Use the list form of system or exec. It does not send the command through a shell.
use v5.36;
sub run_command(@cmd) {
@cmd or die "Command is required\n";
system { $cmd[0] } @cmd;
die "Could not start command: $!\n" if $? == -1;
die "Command ended by signal\n" if $? & 127;
my $exit = $? >> 8;
die "Command failed with exit code $exit\n" if $exit != 0;
}
run_command('/usr/bin/grep', '--fixed-strings', '--', $user_text, $safe_file);Use full command paths. Put -- before user values when the tool supports it. This stops a value such as -r from being read as an option.
Do not use shell strings, backticks, qx//, or pipe forms of open with user data:
system("grep '$user_text' $user_file");
my $out = `ls $user_dir`;
open my $fh, "| $user_command";For input and output, use a module that accepts an argument list, such as IPC::Run3:
use v5.36;
use IPC::Run3 qw(run3);
sub capture_output(@cmd) {
my ($stdout, $stderr);
run3(\@cmd, \undef, \$stdout, \$stderr);
if ($? != 0) {
my $exit = $? >> 8;
die "Command failed with exit code $exit\n";
}
return $stdout;
}Add time and output size limits for commands that may hang or print too much. Run them with the least rights they need.
Safe SQL with DBI
Use placeholders for every data value:
use v5.36;
use DBI;
my $dbh = DBI->connect($dsn, $db_user, $db_pass, {
RaiseError => 1,
PrintError => 0,
AutoCommit => 1,
});
sub find_user($dbh, $email) {
my $sth = $dbh->prepare(
'SELECT id, name, email FROM users WHERE email = ?'
);
$sth->execute($email);
return $sth->fetchrow_hashref;
}
sub search_users($dbh, $name, $status) {
my $sth = $dbh->prepare(
'SELECT id, name, email
FROM users
WHERE name LIKE ? AND status = ?
ORDER BY name'
);
$sth->execute("%$name%", $status);
return $sth->fetchall_arrayref({});
}A placeholder can hold a value. It cannot hold a table name, column name, sort order, or SQL keyword. Use an allowlist for those parts:
sub list_users($dbh, $column, $direction) {
my %columns = (
name => 'name',
email => 'email',
created_at => 'created_at',
);
my %directions = (
asc => 'ASC',
desc => 'DESC',
);
my $safe_column = $columns{$column}
// die "Invalid sort column\n";
my $safe_direction = $directions{lc($direction // '')}
// die "Invalid sort direction\n";
my $sql = "SELECT id, name, email
FROM users
ORDER BY $safe_column $safe_direction";
return $dbh->selectall_arrayref($sql, { Slice => {} });
}Also follow these rules:
- Use a database account with few rights.
- Use a transaction for steps that must all pass or all fail.
- Set row limits for searches.
- Do not place secret data in SQL error text.
- Do not log full query values.
- Check ORM raw SQL features with the same care.
Web Security
Stop XSS
Escape data for the place where it will be used.
use v5.36;
use HTML::Entities qw(encode_entities);
use JSON::MaybeXS qw(encode_json);
use URI::Escape qw(uri_escape_utf8);
sub html_text($value) {
return encode_entities($value // '');
}
sub url_value($value) {
return uri_escape_utf8($value // '');
}
sub json_text($data) {
return encode_json($data);
}HTML text, HTML marks, links, JavaScript, CSS, URLs, and JSON are different places. One escape rule does not fit them all.
Use your template tool's auto-escape feature:
# Mojolicious:
# <%= $user_text %> safely escapes HTML.
# <%== $trusted_html %> writes raw HTML.
# Template Toolkit:
# [% user_text | html %]Avoid raw HTML output. If users may write some HTML, clean it with a tested HTML cleaner and a small allowlist.
For links made from user data, allow only safe schemes such as https. Block javascript:, data:, and other schemes unless the app truly needs them.
Set a Content Security Policy as a second guard. Do not use it in place of output escaping.
Stop CSRF
All requests that change data need CSRF checks. This includes POST, PUT, PATCH, and DELETE.
Use the CSRF feature from your web framework when it has one. A safe token must:
- Come from a strong random source.
- Be tied to the user session.
- Be checked with a safe compare.
- Not appear in a URL.
- Be renewed after login or a rights change.
Example with a session token:
use v5.36;
use Crypt::URandom qw(urandom);
use MIME::Base64 qw(encode_base64url);
use String::Compare::ConstantTime qw(constant_time_compare);
sub new_csrf_token() {
return encode_base64url(urandom(32));
}
sub check_csrf($session_token, $form_token) {
defined $session_token && defined $form_token
or die "Missing CSRF token\n";
length($session_token) == length($form_token)
or die "Invalid CSRF token\n";
constant_time_compare($session_token, $form_token)
or die "Invalid CSRF token\n";
return 1;
}Do not use GET for a change. SameSite cookies help, but they do not replace a CSRF token.
For JSON APIs, check the content type and use a clear login rule. Do not allow any web site to send logged-in requests through loose CORS rules.
Cookies and Sessions
Use safe cookie flags:
Secure
HttpOnly
SameSite=LaxUse SameSite=Strict when the app flow allows it. Use SameSite=None only with Secure and only when cross-site use is required.
Also:
- Use HTTPS for the whole session.
- Make a new session ID after login.
- Make a new session ID after a rights change.
- End the old session at logout.
- Set idle and full life limits.
- Store little data in the cookie.
- Sign or encrypt client-side session data.
- Do not place passwords or secret keys in a cookie.
Passwords
Do not store plain passwords. Do not use MD5, SHA-1, or a plain SHA hash for passwords.
Use a tested password module with Argon2id or bcrypt. Store the full hash string made by the module. It includes the salt and work settings.
use v5.36;
use Crypt::Argon2 qw(argon2id_pass argon2id_verify);
sub hash_password($password) {
defined $password && length($password) >= 12
or die "Password is too short\n";
return argon2id_pass($password);
}
sub password_matches($encoded_hash, $password) {
return argon2id_verify($encoded_hash, $password);
}Use the current module guide to pick work settings that fit the app. Add rate limits to login and password reset routes.
Do not log passwords, reset tokens, or session tokens.
Uploads
For file uploads:
- Set a small size limit.
- Make a new server-side file name.
- Store files outside the web root.
- Check content, not only the file name.
- Do not run uploaded files.
- Serve files with a safe content type.
- Add
Content-Disposition: attachmentwhen the file should download. - Remove active content when the app does not need it.
- Limit image size before image work to stop memory use attacks.
Redirects
Do not redirect to any user-given URL. Use a short allowlist or allow only a local path.
sub safe_next_path($path) {
defined $path or return '/';
$path =~ m{\A/[A-Za-z0-9/_-]*\z}
or return '/';
return $path;
}Block values that start with //. They may send the user to another site.
Secrets and Error Messages
Read secrets from a protected config source. Do not put secrets in source code.
Never log:
- Passwords.
- API keys.
- Session IDs.
- CSRF tokens.
- Reset links.
- Full credit card data.
- Full request bodies without a clear need.
Show users a short error. Log a private error with a request ID. Do not show stack traces, file paths, SQL text, or secret values in a live app.
eval {
do_private_work();
1;
} or do {
my $request_id = make_request_id();
warn "Request $request_id failed\n";
show_error("The request failed. Error ID: $request_id");
};Safe Parsing and Loading
Do not run text as Perl code. Avoid string eval with user data.
eval $user_text;Do not load a module name or file path from user input. Use an allowlist that maps a short name to a known module.
Treat unsafe data tools with care. Do not use Storable::thaw on data from users or the network. For plain data, prefer JSON and check the result after parsing.
Set depth, size, and item limits for JSON, YAML, XML, and other input. Turn off XML network access and outside entity loading.
perlcritic Checks
Run perlcritic as one check:
perlcritic --severity 3 lib/ script/Useful policies include:
InputOutput::ProhibitTwoArgOpen
InputOutput::ProhibitBacktickOperators
InputOutput::RequireCheckedSyscalls
InputOutput::RequireCheckedOpen
BuiltinFunctions::ProhibitStringyEval
BuiltinFunctions::ProhibitStringySplit
ValuesAndExpressions::ProhibitInterpolationOfLiteralsAdd project rules in .perlcriticrc. Do not turn off a rule without a short reason. A clean perlcritic run does not prove that the app is safe. Review data flow and access rights too.
Concrete Example
This CGI-style example reads a user name, checks it, uses a DBI placeholder, and escapes the HTML output:
#!/usr/bin/perl -T
use v5.36;
use CGI qw(param header);
use DBI;
use HTML::Entities qw(encode_entities);
$ENV{PATH} = '/usr/bin:/bin';
delete @ENV{qw(IFS CDPATH ENV BASH_ENV)};
sub valid_username($input) {
defined $input or die "Username is required\n";
$input =~ /\A([A-Za-z0-9_]{3,30})\z/
or die "Invalid username\n";
return $1;
}
my $name = valid_username(param('name'));
my $dbh = DBI->connect($dsn, $db_user, $db_pass, {
RaiseError => 1,
PrintError => 0,
AutoCommit => 1,
});
my $row = $dbh->selectrow_hashref(
'SELECT id, name FROM users WHERE name = ?',
undef,
$name,
);
print header(
-type => 'text/html',
-charset => 'UTF-8',
-x_content_type_options => 'nosniff',
);
my $safe_name = encode_entities($row ? $row->{name} : $name);
print "<p>User: $safe_name</p>\n";In a real web app, also use the framework's session, CSRF, cookie, header, and error tools.
Review List
Before release, check that:
- Outside data has type, size, range, and form checks.
- Each check uses an allowlist when possible.
- Tainted data is captured only by a strict regex.
- File access uses three-argument
openor safesysopen. - User paths stay under a fixed folder.
- New files use safe modes and one-step creation.
- Commands use an argument list and a full path.
- User values cannot become command options.
- SQL values use placeholders.
- SQL names and sort words use fixed maps.
- HTML, URL, and JSON output use the right escape rule.
- Each state change has CSRF checks.
- Cookies use safe flags.
- Passwords use Argon2id or bcrypt.
- Uploads have type, size, name, and storage rules.
- Secrets do not enter code, URLs, logs, or user errors.
- Parsers have size and depth limits.
- The app uses the least file, process, and database rights.
- Tests cover bad input and limit cases.
perlcriticruns in local checks and CI.