ソースコードビューア CGI を作る

日記内に登場した CGIソースコードが簡単に見られるように、簡単なソースコードビューア view.cgi を作ってみたいと思います。(^_^)
考えたのは「view.cgi?ソースファイル名」と、QUERY_STRING を使って表示したいソースファイルの名前を渡し、色分けして表示させる、というものです。
この手の「ファイル名をパラメータとして渡す」スクリプトで絶対に注意しなくてはいけないのが、セキュリティの問題ですね。この場合 QUERY_STRING で渡されたファイル名をそのまま open に使うと、表示させたくないファイルの中身まで見る事ができてしまいます。
例えば「view.cgi?/etc/passwd」や「view.cgi?../../../.htpasswd」などの「絶対パス」や「親方向への相対パス」などを渡されてしまうと、実際にはアクセスできないはずのパスワードファイルの内容や各種設定情報などが簡単に漏れてしまい、とても危険です。
この種のセキュリティホールを放置してしまい、クラックされてしまった方も実際にいらっしゃいます。(猿真似は危険『「Ajax + PHP」でRSSリーダーを作る』で破滅


今回の CGI スクリプトでは、この問題の解決策として

  • 「/」で始まる絶対パスは受け付けない
  • 英数字と拡張子用の「.」で構成されたパスしか受け付けない
  • 決められた拡張子のファイルしか受け付けない

という3つのルールを決めました。これにより、指定したディレクトリ以下の、決められた拡張子のファイルしか見れないようにします。


構文を色分けする為に CPAN を「Highlight」で検索すると、ソースコードを HTML でマークアップしてくれるモジュールがいくつか見つかりました。今回は、使い方が簡単で、依存関係が少ない Text::Highlight モジュールを利用したいと思います。Perl や HTML などの色分けに対応しています。
CPAN からローカルにインストールします。

perl -MCPAN -e shell
cpan> install Text::Highlight

これでインストールできました。「perldoc Text::Highlight」を実行してみると、ドキュメントが表示されます。

NAME
    Text::Highlight - Syntax highlighting framework

SYNOPSIS
       use Text::Highlight 'preload';
       my $th = new Text::Highlight(wrapper => "<pre>%s</pre>\n");
       print $th->highlight('Perl', $code);

使い方も簡単ですね。(^_^)


Text::Highlight の使用例(SYNOPSIS)を見てもわかるように、色分けする「言語」を指定しなければいけません。そこで、以下のようなハッシュを用意して、拡張子に関連付けられた言語(ファイルタイプ)がわかるようにします。

my %file_types = (
  pl   => 'Perl',
  pm   => 'Perl',
  cgi  => 'Perl',
  tmpl => 'HTML',
);

この場合、拡張子が「pl」「pm」「cgi」のどれかなら「Perl」として、「tmpl」なら「HTML」として色分けされるようにしたいと思います。
そこで、パスから拡張子を自動的に取得して、ファイルタイプを返す関数を作りました。

# ファイルに関連付けられたファイルタイプを返す
# 関連付けられていない場合は undef を返す
sub get_file_type($) {
  my $fpath = shift;

  my $ftype = undef;
  if ($fpath && $fpath =~ /\.([^.]+?)$/) {
    my $ext = lc($1);
    $ftype = $file_types{$ext} if exists $file_types{$ext};
  }
  return $ftype;
}

正規表現を使って拡張子を取得し、lc 関数で全て小文字に変換します。%file_types に拡張子があれば、そのファイルタイプを、なければ undef が返ります。


また %file_types ハッシュを利用して、先ほど決めたルールをチェックする為の正規表現も作りました。

my $allowed_path = q!^\w[\w\/]+\.(! . join('|', keys %file_types) . q!)$!;
  • 「/」で始まる絶対パスは受け付けない
    • 先頭が英数字(\w)から始まるものにのみマッチする事で対応
  • 英数字と拡張子用の「.」で構成されたパスしか受け付けない
    • 英数字(\w)と「/」(スラッシュ)で構成されたパスにのみマッチするので「..」などは使えない
    • 「.」は拡張子の前でしか使えない
  • 決められた拡張子のファイルしか受け付けない
    • 先ほど作った %file_types を利用して、そのキー(拡張子)のリストを「|」(OR)で join する事により、%file_types に存在する拡張子のファイルしか受け付けない

この正規表現を、渡されたパスに対して適用する事で、有効なパスかどうか調べる事ができます。

# 読み込みが許可されたパスなら真を返す
sub is_allowed_path($) {
  my $fpath = shift;
  return ($fpath && $fpath =~ /$allowed_path/);
}


パスとファイルタイプを受け取って、ソースファイルを読み込む関数も作りました。

# ソースファイルを読み込んでマークアップされた内容を返す
# 読み込めなかった場合は undef を返す
sub load_source_file($$) {
  my ($fpath, $ftype) = @_;

  open IN, $fpath or return undef;
  my $content = join "", <IN>;
  close IN;

  my $th = Text::Highlight->new(wrapper => "%s");
  return $th->highlight($ftype, $content);
}

Text::Highlight を使って色分けした結果を返しています。


あとは、定義した関数を使ってメイン処理を書くだけです。勉強した HTML::Template を使って、HTML 文はテンプレートファイルに切り分ける事にしました。

my $tmpl = HTML::Template->new(filename => $tmpl_path);

my $fname = $ENV{'QUERY_STRING'};   # 渡されたファイル名
my $fpath = "$base_path$fname";     # 実際のファイルパス
my $ftype = get_file_type($fpath);  # 関連付けられたファイルタイプ

# 読み込み可能で関連付けられたパスなら内容を読み込んで設定
if (is_allowed_path($fname) && -r $fpath && $ftype) {
  $tmpl->param(
    FILENAME => $fname,
    FILEPATH => $fpath,
    FILETYPE => $ftype,
    CODE => load_source_file($fpath, $ftype) || "",
  );
}

# ページを出力
my $output = $tmpl->output() || "Template Output Error";
print "Content-Type: text/html\n";
print "Content-Length: " . length($output) . "\n\n";
print $output;

QUERY_STRING でファイル名を受け取ります。$fpath には、実際のファイルパスが入るようにしました。「渡されたファイルパス」と「実際のファイルパス」を区別する為に、2つの変数に分けています。あらかじめ $base_path に、実際に表示するファイルが置かれているディレクトリのパスを指定しておきます。
「-r $fpath」ですが、これは「$fpath が読み込み可能かどうか」を調べる「ファイルテスト演算子」の一種です。ファイルテスト演算子については、別のエントリで詳しく勉強したいと思います。(^_^)
読み込みが可能で(許可されていて)、ファイルタイプが関連付けられたファイルパスなら、HTML::Template オブジェクトの param メソッドで、各種パラメータを設定します。テンプレート側で、パラメータがある場合とない場合の表示を切り替えられるので、これで十分ですね。とても便利です。(^_^)
最後に、ページを出力します。ヘッダーとして Content-Length (ページのサイズ)を出力するようにしました。無くても構わないのですが、Content-Length を示しておくと、ウェブブラウザ側でダウンロードの進行状態を表示する事ができます。
メイン処理がちょっと長くなってしまいましたが……、ページの出力を関数化するのも冗長だと思いますし、テンプレートオブジェクトを関数に渡すというのもちょっと気持ち悪いので、このようになりました。コードの切り分けは難しいですね。(^_^;)


というわけで、完成したソースコードを、完成したソースビューアで見たのがこちらです。(^_^)
http://palmo.is.land.to/cgi/view.cgi?view.cgi
ただ色分け表示するだけではつまらないので、フォントや文字の大きさ、色のテーマなどを設定できるようにしてみました。JavaScript が必要です。「設定を記憶」ボタンを押すと、現在の設定をクッキーに記憶します。クッキーを削除するには「設定を忘れる」ボタンを押します。
また、書き忘れていましたが CGI::Carp モジュールを使って、致命的なエラーが発生した時はブラウザに表示するようにしました。
ちなみに、テンプレートファイルはこちらになります。
view.tmpl をソースビューアで見る
CGI を作るのはとても楽しいですね。JavaScript のようにクライアントサイドで動くものも楽しいですが、サーバーサイドだとファイルの読み書きなども制限無くできますし、どんな環境でもブラウザさえあれば利用できます。


ところで、サーバーで Text::Highlight モジュールを利用する為に、view.cgi と同じディレクトリに「Text」ディレクトリ、その中に「Highlight」ディレクトリを作り、CPAN でダウンロードしてきた Text::Highlight のコードをアップロードしたのですが、コンパイルが必要なモジュールはサーバー側で make しないとまずいのでしょうか……?
だとすると、TelnetSSH が使えないとダメですよね。うーん、どうしよう。(^_^;)