---
name: perl-security
description: Secure Perl code with taint mode, strict input checks, safe file and process use, DBI placeholders, web guards, and perlcritic checks.
origin: ECC
---

# 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:

1. Treat all outside data as unsafe.
2. Check type, size, form, and allowed values.
3. Use an allowlist when you can.
4. Keep user data out of shell command strings.
5. Use DBI placeholders for SQL values.
6. Escape output for its exact use.
7. Use safe file paths and file modes.
8. Hide secret data from logs and errors.
9. Use tested security modules for passwords and CSRF.
10. 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:

```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:

```perl
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:

```perl
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

```perl
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:

```perl
my $bad_one = qr/\A(a+)+\z/;
my $bad_two = qr/\A([A-Za-z]+)*\z/;
```

Use a simple form:

```perl
my $good_one = qr/\Aa+\z/;
my $good_two = qr/\A[A-Za-z]+\z/;
```

Also limit input size before a regex check:

```perl
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:

```perl
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`

```perl
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:

```perl
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.

```perl
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:

```perl
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::Temp` for temp files.
- Set private modes such as `0600` when needed.
- Use `flock` when 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.

```perl
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:

```perl
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`:

```perl
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:

```perl
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:

```perl
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.

```perl
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:

```perl
# 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:

```perl
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:

```text
Secure
HttpOnly
SameSite=Lax
```

Use `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.

```perl
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: attachment` when 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.

```perl
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.

```perl
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.

```perl
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:

```sh
perlcritic --severity 3 lib/ script/
```

Useful policies include:

```text
InputOutput::ProhibitTwoArgOpen
InputOutput::ProhibitBacktickOperators
InputOutput::RequireCheckedSyscalls
InputOutput::RequireCheckedOpen
BuiltinFunctions::ProhibitStringyEval
BuiltinFunctions::ProhibitStringySplit
ValuesAndExpressions::ProhibitInterpolationOfLiterals
```

Add 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:

```perl
#!/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 `open` or safe `sysopen`.
- 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.
- `perlcritic` runs in local checks and CI.