| name | perl-security |
| description | テイントモード、入力バリデーション、安全なプロセス実行、DBIパラメータ化クエリ、Webセキュリティ(XSS/SQLi/CSRF)、perlcriticセキュリティポリシーを網羅する包括的なPerlセキュリティ。 |
| origin | ECC |
Perlセキュリティパターン
入力バリデーション、インジェクション防止、セキュアコーディングプラクティスを網羅するPerlアプリケーションの包括的なセキュリティガイドライン。
アクティベートするタイミング
- Perlアプリケーションでユーザー入力を処理するとき
- PerlのWebアプリケーション(CGI、Mojolicious、Dancer2、Catalyst)を構築するとき
- セキュリティ脆弱性についてPerlコードをレビューするとき
- ユーザー指定パスでファイル操作を実行するとき
- PerlからシステムコマンドをExecuteするとき
- DBIデータベースクエリを書くとき
仕組み
テイント対応の入力境界から始め、次に外側に移動する: 入力をバリデートしてアンテイントし、ファイルシステムとプロセス実行を制約内に保ち、どこでもパラメータ化されたDBIクエリを使用する。以下の例は、ユーザー入力、シェル、またはネットワークに触れるPerlコードをリリースする前に適用することが期待されるデフォルトを示す。
テイントモード
Perlのテイントモード(-T)は外部ソースからのデータを追跡し、明示的なバリデーションなしに安全でない操作で使用されることを防ぐ。
テイントモードの有効化
use v5.36;
my $input = $ARGV[0];
my $env_path = $ENV{PATH};
my $form = <STDIN>;
my $query = $ENV{QUERY_STRING};
$ENV{PATH} = '/usr/local/bin:/usr/bin:/bin';
delete @ENV{qw(IFS CDPATH ENV BASH_ENV)};
アンテイントパターン
use v5.36;
sub untaint_username($input) {
if ($input =~ /^([a-zA-Z0-9_]{3,30})$/) {
return $1;
}
die "Invalid username: must be 3-30 alphanumeric characters\n";
}
sub untaint_filename($input) {
if ($input =~ m{^([a-zA-Z0-9._-]+)$}) {
return $1;
}
die "Invalid filename: contains unsafe characters\n";
}
sub bad_untaint($input) {
$input =~ /^(.*)$/s;
return $1;
}
入力バリデーション
ブロックリストよりアローリスト
use v5.36;
sub validate_sort_field($field) {
my %allowed = map { $_ => 1 } qw(name email created_at updated_at);
die "Invalid sort field: $field\n" unless $allowed{$field};
return $field;
}
sub validate_email($email) {
if ($email =~ /^([a-zA-Z0-9._%+-]+\@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/) {
return $1;
}
die "Invalid email address\n";
}
sub validate_integer($input) {
if ($input =~ /^(-?\d{1,10})$/) {
return $1 + 0;
}
die "Invalid integer\n";
}
sub bad_validate($input) {
die "Invalid" if $input =~ /[<>"';&|]/;
return $input;
}
長さ制約
use v5.36;
sub validate_comment($text) {
die "Comment is required\n" unless length($text) > 0;
die "Comment exceeds 10000 chars\n" if length($text) > 10_000;
return $text;
}
安全な正規表現
ReDoS防止
壊滅的なバックトラッキングは重複するパターンにネストされた量詞が使用されるときに発生する。
use v5.36;
my $bad_re = qr/^(a+)+$/;
my $bad_re2 = qr/^([a-zA-Z]+)*$/;
my $bad_re3 = qr/^(.*?,){10,}$/;
my $good_re = qr/^a+$/;
my $good_re2 = qr/^[a-zA-Z]+$/;
my $safe_re = qr/^[a-zA-Z]++$/;
my $safe_re2 = qr/^(?>a+)$/;
use POSIX qw(alarm);
sub safe_match($string, $pattern, $timeout = 2) {
my $matched;
eval {
local $SIG{ALRM} = sub { die "Regex timeout\n" };
alarm($timeout);
$matched = $string =~ $pattern;
alarm(0);
};
alarm(0);
die $@ if $@;
return $matched;
}
安全なファイル操作
3引数open
use v5.36;
sub read_file($path) {
open my $fh, '<:encoding(UTF-8)', $path
or die "Cannot open '$path': $!\n";
local $/;
my $content = <$fh>;
close $fh;
return $content;
}
sub bad_read($path) {
open my $fh, $path;
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 "Cannot create '$path': $!\n";
return $fh;
}
sub safe_path($base_dir, $user_path) {
my $real = realpath(File::Spec->catfile($base_dir, $user_path))
// die "Path does not exist\n";
my $base_real = realpath($base_dir)
// die "Base dir does not exist\n";
die "Path traversal blocked\n" unless $real =~ /^\Q$base_real\E(?:\/|\z)/;
return $real;
}
一時ファイルにはFile::Temp(tempfile(UNLINK => 1))を使用し、レースコンディションを防ぐためにflock(LOCK_EX)を使用する。
安全なプロセス実行
リスト形式のsystemとexec
use v5.36;
sub run_command(@cmd) {
system(@cmd) == 0
or die "Command failed: @cmd\n";
}
run_command('grep', '-r', $user_pattern, '/var/log/app/');
use IPC::Run3;
sub capture_output(@cmd) {
my ($stdout, $stderr);
run3(\@cmd, \undef, \$stdout, \$stderr);
if ($?) {
die "Command failed (exit $?): $stderr\n";
}
return $stdout;
}
sub bad_search($pattern) {
system("grep -r '$pattern' /var/log/app/");
}
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,
});
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({});
}
sub bad_find($dbh, $email) {
my $sth = $dbh->prepare("SELECT * FROM users WHERE email = '$email'");
$sth->execute;
return $sth->fetchrow_hashref;
}
動的カラムアローリスト
use v5.36;
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 "Invalid column: $column\n" unless $allowed_cols{$column};
die "Invalid direction: $direction\n" unless $allowed_dirs{uc $direction};
my $sth = $dbh->prepare("SELECT * FROM users ORDER BY $column $direction");
$sth->execute;
return $sth->fetchall_arrayref({});
}
sub bad_order($dbh, $column) {
$dbh->prepare("SELECT * FROM users ORDER BY $column");
}
DBIx::Class(ORM安全性)
use v5.36;
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);
sub safe_html($user_input) {
return encode_entities($user_input);
}
sub safe_url_param($value) {
return uri_escape_utf8($value);
}
use JSON::MaybeXS qw(encode_json);
sub safe_json($data) {
return encode_json($data);
}
sub bad_html($input) {
print "<div>$input</div>";
}
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;
$app->secrets(['long-random-secret-rotated-regularly']);
$app->sessions->secure(1);
$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セキュリティポリシー
severity = 3
theme = security + core
[InputOutput::RequireThreeArgOpen]
severity = 5
[InputOutput::RequireCheckedSyscalls]
functions = :builtins
severity = 4
[BuiltinFunctions::ProhibitStringyEval]
severity = 5
[InputOutput::ProhibitBacktickOperators]
severity = 4
[Modules::RequireTaintChecking]
severity = 5
[InputOutput::ProhibitTwoArgOpen]
severity = 5
[InputOutput::ProhibitBarewordFileHandles]
severity = 5
perlcriticの実行
perlcritic --severity 3 --theme security lib/MyApp/Handler.pm
perlcritic --severity 3 --theme security lib/
perlcritic --severity 4 --theme security --quiet lib/ || exit 1
クイックセキュリティチェックリスト
| チェック | 確認事項 |
|---|
| テイントモード | CGI/Webスクリプトの-Tフラグ |
| 入力バリデーション | アローリストパターン、長さ制限 |
| ファイル操作 | 3引数open、パストラバーサルチェック |
| プロセス実行 | リスト形式のsystem、シェル補間なし |
| SQLクエリ | DBIプレースホルダー、補間しない |
| HTML出力 | encode_entities()、テンプレート自動エスケープ |
| CSRFトークン | 生成され、状態変更リクエストで検証される |
| セッション設定 | Secure、HttpOnly、SameSiteクッキー |
| HTTPヘッダー | CSP、X-Frame-Options、HSTS |
| 依存関係 | ピン留めされたバージョン、監査されたモジュール |
| 正規表現の安全性 | ネストされた量詞なし、アンカーされたパターン |
| エラーメッセージ | スタックトレースやパスがユーザーに漏れない |
アンチパターン
open my $fh, $user_input;
system("convert $user_file output.png");
$dbh->do("DELETE FROM users WHERE id = $id");
eval $user_code;
my $path = $ENV{UPLOAD_DIR};
system("ls $path");
($input) = $input =~ /(.*)/s;
print "<div>Welcome, $username!</div>";
print $cgi->redirect($user_url);
忘れないこと: Perlの柔軟性は強力だが規律が必要。Webに面したコードにはテイントモードを使用し、アローリストですべての入力をバリデートし、すべてのクエリにDBIプレースホルダーを使用し、すべての出力をそのコンテキスト用にエンコードする。多層防御 — 単一の層に依存しない。