Perl セキュリティパターン
Perlアプリケーションにおける、入力検証、インジェクション防止、安全なコーディング手法を網羅した包括的なセキュリティガイドライン。
アクティベーション条件
- Perlアプリケーションでユーザー入力を扱う場合
- Perl Webアプリケーション(CGI、Mojolicious、Dancer2、Catalyst)を構築する場合
- Perlコードのセキュリティ脆弱性をレビューする場合
- ユーザー指定のパスでファイル操作を行う場合
- Perlからシステムコマンドを実行する場合
- DBIデータベースクエリを作成する場合
動作の仕組み
まず、汚染を認識した入力境界から開始し、外側に向かって処理を進めます。つまり、入力を検証・汚染解除し、ファイルシステムとプロセス実行を制約し、すべてのDBIクエリでパラメータ化クエリを使用します。以下の例は、このスキルが、ユーザー入力、シェル、またはネットワークに触れるPerlコードをリリースする前に適用すべき安全なデフォルトを示しています。
汚染モード
Perlの汚染モード(-T)は、外部ソースからのデータを追跡し、明示的な検証なしに安全でない操作で使用されるのを防ぎます。
汚染モードの有効化
#!/usr/bin/perl -T
use v5.36;
# 汚染: プログラム外部からの全てのもの
my $input = $ARGV[0]; # 汚染
my $env_path = $ENV{PATH}; # 汚染
my $form = <STDIN>; # 汚染
my $query = $ENV{QUERY_STRING}; # 汚染
# PATHを早期にサニタイズ(汚染モードで必須)
$ENV{PATH} = '/usr/local/bin:/usr/bin:/bin';
delete @ENV{qw(IFS CDPATH ENV BASH_ENV)};
汚染解除パターン
use v5.36;
# Good: 特定の正規表現で検証し、汚染解除
sub untaint_username($input) {
if ($input =~ /^([a-zA-Z0-9_]{3,30})$/) {
return $1; # $1は汚染解除済み
}
die "ユーザー名が無効です: 3〜30文字の英数字である必要があります\n";
}
# Good: ファイルパスを検証し汚染解除
sub untaint_filename($input) {
if ($input =~ m{^([a-zA-Z0-9._-]+)$}) {
return $1;
}
die "ファイル名が無効です: 安全でない文字が含まれています\n";
}
# Bad: 過度に寛容な汚染解除(目的を無効化)
sub bad_untaint($input) {
$input =~ /^(.*)$/s;
return $1; # 何でも受け入れる — 無意味
}
入力検証
ブロックリストより許可リスト
use v5.36;
# Good: 許可リスト — 許可されるものを正確に定義
sub validate_sort_field($field) {
my %allowed = map { $_ => 1 } qw(name email created_at updated_at);
die "ソートフィールドが無効です: $field\n" unless $allowed{$field};
return $field;
}
# Good: 特定のパターンで検証
sub validate_email($email) {
if ($email =~ /^([a-zA-Z0-9._%+-]+\@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/) {
return $1;
}
die "メールアドレスが無効です\n";
}
sub validate_integer($input) {
if ($input =~ /^(-?\d{1,10})$/) {
return $1 + 0; # 数値に変換
}
die "整数が無効です\n";
}
# Bad: ブロックリスト — 常に不完全
sub bad_validate($input) {
die "無効" if $input =~ /[<>"';&|]/; # エンコードされた攻撃を見逃す
return $input;
}
長さ制約
use v5.36;
sub validate_comment($text) {
die "コメントは必須です\n" unless length($text) > 0;
die "コメントが10000文字を超えています\n" if length($text) > 10_000;
return $text;
}
安全な正規表現
ReDoS 対策
壊滅的なバックトラッキングは、重複するパターンにネストされた量指定子がある場合に発生します。
use v5.36;
# Bad: ReDoSに対して脆弱(指数関数的なバックトラッキング)
my $bad_re = qr/^(a+)+$/; # ネストされた量指定子
my $bad_re2 = qr/^([a-zA-Z]+)*$/; # クラスに対するネストされた量指定子
my $bad_re3 = qr/^(.*?,){10,}$/; # 繰り返される貪欲/非貪欲の組み合わせ
# Good: ネストなしで書き直す
my $good_re = qr/^a+$/; # 単一の量指定子
my $good_re2 = qr/^[a-zA-Z]+$/; # クラスに対する単一の量指定子
# Good: 占有量指定子またはアトミックグループを使用してバックトラッキングを防止
my $safe_re = qr/^[a-zA-Z]++$/; # 占有 (5.10以降)
my $safe_re2 = qr/^(?>a+)$/; # アトミックグループ
# Good: 信頼できないパターンにはタイムアウトを適用
use POSIX qw(alarm);
sub safe_match($string, $pattern, $timeout = 2) {
my $matched;
eval {
local $SIG{ALRM} = sub { die "正規表現タイムアウト\n" };
alarm($timeout);
$matched = $string =~ $pattern;
alarm(0);
};
alarm(0);
die $@ if $@;
return $matched;
}
安全なファイル操作
3引数open
use v5.36;
# Good: 3引数open、レキシカルファイルハンドル、戻り値チェック
sub read_file($path) {
open my $fh, '<:encoding(UTF-8)', $path
or die "'$path' を開けません: $!\n";
local $/;
my $content = <$fh>;
close $fh;
return $content;
}
# Bad: ユーザーデータを使用した2引数open(コマンドインジェクション)
sub bad_read($path) {
open my $fh, $path; # $path = "|rm -rf /" の場合、コマンド実行!
open my $fh, "< $path"; # シェルメタ文字インジェクション
}
TOCTOU 防止とパストラバーサル
use v5.36;
use Fcntl qw(:DEFAULT :flock);
use File::Spec;
use Cwd qw(realpath);
# アトミックなファイル作成
sub create_file_safe($path) {
sysopen(my $fh, $path, O_WRONLY | O_CREAT | O_EXCL, 0600)
or die "'$path' を作成できません: $!\n";
return $fh;
}
# パスが許可されたディレクトリ内に留まることを検証
sub safe_path($base_dir, $user_path) {
my $real = realpath(File::Spec->catfile($base_dir, $user_path))
// die "パスが存在しません\n";
my $base_real = realpath($base_dir)
// die "ベースディレクトリが存在しません\n";
die "パストラバーサルをブロックしました\n" unless $real =~ /^\Q$base_real\E(?:\/|\z)/;
return $real;
}
一時ファイルには File::Temp(tempfile(UNLINK => 1))、レースコンディション防止には flock(LOCK_EX) を使用します。
安全なプロセス実行
list形式のsystemとexec
use v5.36;
# Good: list形式 — シェルによる解釈なし
sub run_command(@cmd) {
system(@cmd) == 0
or die "コマンドが失敗しました: @cmd\n";
}
run_command('grep', '-r', $user_pattern, '/var/log/app/');
# Good: IPC::Run3で安全に出力を取得
use IPC::Run3;
sub capture_output(@cmd) {
my ($stdout, $stderr);
run3(\@cmd, \undef, \$stdout, \$stderr);
if ($?) {
die "コマンドが失敗しました (exit $?): $stderr\n";
}
return $stdout;
}
# Bad: 文字列形式 — シェルインジェクション!
sub bad_search($pattern) {
system("grep -r '$pattern' /var/log/app/"); # $pattern = "'; rm -rf / #" の場合
}
# Bad: 変数展開を含むバッククォート
my $output = `ls $user_dir`; # シェルインジェクションのリスク
また、外部コマンドからのstdout/stderrを安全に取得するために Capture::Tiny を使用することもできます。
SQLインジェクション防止
DBIプレースホルダ
use v5.36;
use DBI;
my $dbh = DBI->connect($dsn, $user, $pass, {
RaiseError => 1,
PrintError => 0,
AutoCommit => 1,
});
# Good: パラメータ化クエリ — 常にプレースホルダを使用
sub find_user($dbh, $email) {
my $sth = $dbh->prepare('SELECT * FROM users WHERE email = ?');
$sth->execute($email);
return $sth->fetchrow_hashref;
}
sub search_users($dbh, $name, $status) {
my $sth = $dbh->prepare(
'SELECT * FROM users WHERE name LIKE ? AND status = ? ORDER BY name'
);
$sth->execute("%$name%", $status);
return $sth->fetchall_arrayref({});
}
# Bad: SQL内の文字列展開(SQLi脆弱性!)
sub bad_find($dbh, $email) {
my $sth = $dbh->prepare("SELECT * FROM users WHERE email = '$email'");
# $email = "' OR 1=1 --" の場合、全てのユーザーを返す
$sth->execute;
return $sth->fetchrow_hashref;
}
動的カラム許可リスト
use v5.36;
# Good: 許可リストに対してカラム名を検証
sub order_by($dbh, $column, $direction) {
my %allowed_cols = map { $_ => 1 } qw(name email created_at);
my %allowed_dirs = map { $_ => 1 } qw(ASC DESC);
die "カラムが無効です: $column\n" unless $allowed_cols{$column};
die "方向が無効です: $direction\n" unless $allowed_dirs{uc $direction};
my $sth = $dbh->prepare("SELECT * FROM users ORDER BY $column $direction");
$sth->execute;
return $sth->fetchall_arrayref({});
}
# Bad: ユーザー選択のカラムを直接展開
sub bad_order($dbh, $column) {
$dbh->prepare("SELECT * FROM users ORDER BY $column"); # SQLi!
}
DBIx::Class (ORM 安全性)
use v5.36;
# DBIx::Classは安全なパラメータ化クエリを生成
my @users = $schema->resultset('User')->search({
status => 'active',
email => { -like => '%@example.com' },
}, {
order_by => { -asc => 'name' },
rows => 50,
});
Webセキュリティ
XSS防止
use v5.36;
use HTML::Entities qw(encode_entities);
use URI::Escape qw(uri_escape_utf8);
# Good: HTMLコンテキスト用に出力をエンコード
sub safe_html($user_input) {
return encode_entities($user_input);
}
# Good: URLコンテキスト用にエンコード
sub safe_url_param($value) {
return uri_escape_utf8($value);
}
# Good: JSONコンテキスト用にエンコード
use JSON::MaybeXS qw(encode_json);
sub safe_json($data) {
return encode_json($data); # エスケープを処理
}
# テンプレートの自動エスケープ (Mojolicious)
# <%= $user_input %> — 自動エスケープ(安全)
# <%== $raw_html %> — 生出力(危険、信頼できるコンテンツにのみ使用)
# テンプレートの自動エスケープ (Template Toolkit)
# [% user_input | html %] — 明示的なHTMLエンコード
# Bad: HTML内での生出力
sub bad_html($input) {
print "<div>$input</div>"; # $input に <script> が含まれるとXSS
}
CSRF対策
use v5.36;
use Crypt::URandom qw(urandom);
use MIME::Base64 qw(encode_base64url);
sub generate_csrf_token() {
return encode_base64url(urandom(32));
}
トークンの検証には、必ず定数時間比較を使用します。ほとんどのWebフレームワーク(Mojolicious、Dancer2、Catalyst)は組み込みのCSRF対策を提供しているため、自作の解決策よりもそれらを優先してください。
セッションとヘッダーセキュリティ
use v5.36;
# Mojolicious セッション + ヘッダー
$app->secrets(['定期的にローテーションされる長いランダムシークレット']);
$app->sessions->secure(1); # HTTPSのみ
$app->sessions->samesite('Lax');
$app->hook(after_dispatch => sub ($c) {
$c->res->headers->header('X-Content-Type-Options' => 'nosniff');
$c->res->headers->header('X-Frame-Options' => 'DENY');
$c->res->headers->header('Content-Security-Policy' => "default-src 'self'");
$c->res->headers->header('Strict-Transport-Security' => 'max-age=31536000; includeSubDomains');
});
出力エンコーディング
常にコンテキストに応じて出力をエンコードします。HTMLには HTML::Entities::encode_entities()、URLには URI::Escape::uri_escape_utf8()、JSONには JSON::MaybeXS::encode_json() を使用します。
CPANモジュールのセキュリティ
- バージョンを固定 cpanfileで:
requires 'DBI', '== 1.643'; - メンテナンスされているモジュールを優先: MetaCPANで最近のリリースを確認
- 依存関係を最小限に: 各依存関係は攻撃対象となります
セキュリティツール
perlcritic セキュリティポリシー
# .perlcriticrc — セキュリティ重視の設定
severity = 3
theme = security + core
# 3引数openを要求
[InputOutput::RequireThreeArgOpen]
severity = 5
# チェック付きシステムコールを要求
[InputOutput::RequireCheckedSyscalls]
functions = :builtins
severity = 4
# 文字列evalを禁止
[BuiltinFunctions::ProhibitStringyEval]
severity = 5
# バッククォート演算子を禁止
[InputOutput::ProhibitBacktickOperators]
severity = 4
# CGIでの汚染チェックを要求
[Modules::RequireTaintChecking]
severity = 5
# 2引数openを禁止
[InputOutput::ProhibitTwoArgOpen]
severity = 5
# 裸のワードファイルハンドルを禁止
[InputOutput::ProhibitBarewordFileHandles]
severity = 5
perlcriticの実行
# ファイルをチェック
perlcritic --severity 3 --theme security lib/MyApp/Handler.pm
# プロジェクト全体をチェック
perlcritic --severity 3 --theme security lib/
# CI統合
perlcritic --severity 4 --theme security --quiet lib/ || exit 1
クイックセキュリティチェックリスト
| チェック項目 | 確認内容 |
|---|---|
| 汚染モード | CGI/Webスクリプトでの -T フラグ |
| 入力検証 | 許可リストパターン、長さ制限 |
| ファイル操作 | 3引数open、パストラバーサルチェック |
| プロセス実行 | list形式のsystem、シェル展開なし |
| SQLクエリ | DBIプレースホルダ、決して展開しない |
| HTML出力 | encode_entities()、テンプレート自動エスケープ |
| CSRFトークン | 状態変更リクエストで生成・検証 |
| セッション設定 | Secure、HttpOnly、SameSiteクッキー |
| HTTPヘッダー | CSP、X-Frame-Options、HSTS |
| 依存関係 | バージョン固定、監査済みモジュール |
| 正規表現の安全性 | ネストされた量指定子なし、アンカー付きパターン |
| エラーメッセージ | スタックトレースやパスがユーザーに漏れないこと |
アンチパターン
# 1. ユーザーデータを使用した2引数open(コマンドインジェクション)
open my $fh, $user_input; # 重大な脆弱性
# 2. 文字列形式のsystem(シェルインジェクション)
system("convert $user_file output.png"); # 重大な脆弱性
# 3. SQL文字列展開
$dbh->do("DELETE FROM users WHERE id = $id"); # SQLi
# 4. ユーザー入力によるeval(コードインジェクション)
eval $user_code; # リモートコード実行
# 5. サニタイズなしで $ENV を信頼
my $path = $ENV{UPLOAD_DIR}; # 操作される可能性あり
system("ls $path"); # 二重脆弱性
# 6. 検証なしでの汚染解除
($input) = $input =~ /(.*)/s; # 怠惰な汚染解除 — 目的を無効化
# 7. HTML内での生ユーザーデータ
print "<div>ようこそ、$username さん!</div>"; # XSS
# 8. 検証されていないリダイレクト
print $cgi->redirect($user_url); # オープンリダイレクト
覚えておいてください: Perlの柔軟性は強力ですが、規律が必要です。Web向けコードには汚染モードを使用し、すべての入力を許可リストで検証し、すべてのクエリにDBIプレースホルダを使用し、すべての出力をそのコンテキストに応じてエンコードしてください。多層防御 — 決して単一の層に依存しないでください。