YAMAGUCHI::weblog

海水パンツとゴーグルで、巨万の富を築きました。カリブの怪物、フリーアルバイター瞳です。

@mitsuhiko寄稿「Python Webアプリケーション開発でのありがちなミス」

はじめに

みなさん、本日はクリスマスですね。Python Webフレームワークアドベントカレンダー2010最終日ですね。最終日はスペシャルゲストということで、pocooの中心メンバである@mitsuhikoが寄稿してくださいました。
pocooはWerkzeug、Jinja2、Flask、Sphinxといった多くの有名なPythonライブラリを提供しているグループで、その中で@mitsuhikoことArmin Ronacherさんはメインコミッタとして活動されています。*1Arminさんは世界指折りのPythonハッカーですが、なんと年齢は21歳!そしてイケメン!ぜひみなさんもArminさんの活動をWatchしてみてください!

寺田さん(@terapyon)からご指名いただいたので、拙訳ながらArminさんの記事の日本語訳をここにご紹介いたします。

概要

Python Webアプリケーションにおけるセキュリティとアーキテクチャの観点でのよくある間違いリスト」
原文はこちら

Python Web開発者としてのよくあるミス

数週間前、私は近くのコミュニティの会合で、Python系やオープンソース系の人々と、Pythonでのパスの結合(os.path.join)がどのように動作するかについて議論したのですが、それはそれは本当にうんざりするほどひどいものでした。
私はいつも皆がどのようにos.path.joinが動作して、なぜそれがそのように動作するのか理解していると確信していました。しかしながら、ちょっとネットで検索してみただけで、 `os.path.join` 関数に任意のフィルタリングされていない値を入力として与えるという、セキュリティ上問題がある間違いが非常に多く見られました。ユーザからの入力が他のシステムから与えられるという状況がもっとも起きやすいのがWeb開発なので、私は人々が盲目的にAPIやOSを信用するような状況がWeb開発以外にもないか調べてみました。

その調査結果をここに記します:私がPythonでWeb開発をする際の「してはいけないことリスト」です。

信用できないデータとファイルシステム

プログラムをGoogle App Engineのような仮想化ファイルシステム上で走らせているのでなければ、重要なファイルがあなたの作ったアプリケーションにもあるファイルアクセス権限でアクセスされてしまうという状況が起こり得ます。ユーザが送信してきたファイル名を盲目的に信用して、そのまま使っても問題ないレベルまで実行ユーザアカウントの権限を下げるというようなことをする開発者はほぼいないと言ってもいいでしょう。典型的な開発者はそういったことはしないからこそ、考えなければいけません。

PHPの世界ではこれはナレッジとしてすでに知られています。もう多くの人がこのような無邪気なコードを書いてしまうからです:

<?php

include "header.php";
$page = isset($_GET['page']) ? $_GET['page'] : 'index';
$filename = $page . '.php';
if (file_exists($filename))
    include $filename;
else
    include "missing_page.php";
include "footer.php";

問題は、ファイル名を盲目的に受け付けるようなコードを書いた場合、誰でも「1階層上に行く」ような文字列を渡しただけで、ファイルシステム上のどこか別のファイルに簡単にアクセス出来てしまうということです。
ここで多くの人が、ファイル名は".php"で終っていないといけないので、PHPのファイルしかアクセスされないのだから、そんなことは問題にはならない、と考えるでしょう。ここでPHPは決して(少なくとも最近まで)ファイルを開く前にヌル文字を削除しないということが分かっています。これによって、ファイルを開いた内部的なC関数がヌル文字を読み込むのをやめます。したがって、もし誰かが ``?page=../../../../htpasswd%00``というページにアクセスした場合、パスワードファイルの中身が覗かれてしまいます。

Pythonプログラマは明らかにこの問題についてあまり気にしていません。なぜならPythonでのファイルを開く関数はこのような問題を抱えておらず、さらにファイルシステムからファイルを読み込むというのはとにかくあまり行われないことだからです。しかしファイル名を扱うという状況によっては、次のようなコードをよく見かけます:

def upload_file(file):
    destination_file = os.path.join(UPLOAD_FOLDER, file.filename)
    with open(destination_file, 'wb') as f:
        copy_fd(file, f)

ここに潜んでいる問題は、`os.path.join`は親フォルダに決して行くことがないと思っていることです。実際は`os.path.join`では親フォルダに行くことができてしまいます:

>>> import os
>>> os.path.join('/var/www/uploads', '../foo')
'/var/www/uploads/../foo'
>>> os.path.join('/var/www/uploads', '/foo')
'/foo'

この例では攻撃者はユーザがアクセスできるファイルシステム上のあらゆるファイルを上書きすることが出来てしまいます。(コードを書き換えて、インジェクションコードを仕込むことができるのです!)一方で、ファイルシステム上のファイルも読み込んでこのように情報をさらけ出してしまうのはよくあることです。

したがって、そうです、`os.path.join`はWebのコンテキストで使うには全然安全ではないのです。様々なライブラリでこの問題を扱う手助けをしてくれています。例えばWerkzeugでは`secure_filename`と呼ばれる関数を持っています。これはファイル名のパス区切り文字、スラッシュ、さらにはパス内の非ASCII文字にいたるまでを取り去ってしまいます。なぜなら文字セットやファイルシステムは非常にトリッキーだからです。本当に最低限としてこれ位はするべきです:

import os, re

_split = re.compile(r'[\0%s]' % re.escape(''.join(
    [os.path.sep, os.path.altsep or ''])))

def secure_filename(path):
    return _split.sub('', path)

このコードはすべてのスラッシュとヌル文字をファイル名から取り去ります。なぜPythonはヌル文字に対して特に問題はないはずなのに、ヌル文字まで取り去ってしまうのでしょうか。なぜならPythonはそうでなくても、あなたのコードには問題があるかもしれないからです。ファイル名内のヌル文字はほとんどの人が想定していないので`TypeError`を引き起こす原因と成り得ます:

>>> open('\0')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: file() argument 1 must be encoded string without NULL bytes, not str

Windows上ではさらにデバイスファイルに続いてはファイル名を付けていないことを理解しておかなければいけません。この問題は今回のエントリの範囲外なのでここでは触れません。興味があったら、Werkzeugではどのように処理しているか確認してみてください。

データとマークアップを混ぜる

このトピックはいつも私をすくみ上がらせます。私はこれが非常によくあることで、多くの人はこれは問題だと思っていないことを知っています。しかし、これが多くの問題やメンテナンス出来ないコードの諸悪の根源なのです。例えばあるデータがあるとします。そのデータはあらゆる目的に沿うためにある最大長の文字列で、文字列は決まった形式になっていることでしょう。例えば、それが平凡な文章で、改行を保存したいが他のすべての空白文字は1つの半角スペースにしたいとします。

非常によくあるパターンです。

しかしそのデータは普通はWebサイトにはHTMLの形で表示されます。だから入力された文字列をデータベースに入れる前に、あらかじめエスケープして、改行を``<br>``に変換するというアイデアを持ち出す人がかならずどこかにいます。これはやってはいけません!

その理由はいくつもあるのですが、最も重要な理由は"コンテキスト"と呼ばれるものです。今日のWebアプリケーションはAPIというコンセプトのおかげで、どんどん複雑になってきています。以前はHTMLフォームだけで可能だったWebサイトの多くの機能は、今やJSONのような他の形式を返すRESTfulなインターフェースでも可能になってきています。

あなたのWebアプリケーションに表示された文字列という意味ではたいてい"HTML"の形になっているでしょう。その場合``<br>``は意味がありますね。しかしもし転送される形式がJSONで、クライアントが受け取って(直接的に)表示する形式がHTMLでなかった場合はどうでしょうか?これは例えばTwitterクライアントなどでありうることです。それにもかかわらずTwitterの誰かが各ツイートのアプリケーション名が付いた文字列はHTMLにすべきだと決めてしまいました。私が初めてTwitter API用のJavaScriptクライアントを書いたときには、jQueryでHTMLをパースして、アプリケーション名を文字列として取得しました。なぜならその文字列にしか興味はなかったからです。欝陶しい話です。しかし自体はさらに悪化します:誰かがこのフィールドに任意のHTMLを埋め込むことが出来てしまうことに気がついたのです。
有名なセキュリティ災害です。

その他の問題として、文字列を再度逆変換しなければいけないときに起きます。たとえば文字列を再度編集できるようにしたいと思った場合、その文字列をアンエスケープしなければならず、もともとあった改行を再度作成しなければなりません。

ここで非常に単純なルールを追加すべきです。(そしてそれは実際とても単純です):データは入力されたまま保存する。1ビットも反転させてはいけません!(もしデータベースに保存する前に唯一許される変換があるとしたらUnicodeへの正規化くらいでしょう)

保存したデータを表示しなければいけない場合は、それを実行する関数を書きましょう。それがボトルネックになる心配があるなら、本当に必要な場合にはmemcacheに載せるか、あるいはデータベースに表示するデータのカラムを追加しておきましょう。しかしその場合は決してHTML形式だけを考えてはいけません。あなたがテキストを転送したいのなら、決してAPI越しにHTML文字列を公開してはいけません。

私はある通知サービスから携帯に通知を受け取っているのですが、そこにウムラウト文字を含まれていると必ず完全に文字列が壊れています。これはつまりあるサービスはHTMLがエスケープされたデータを想定しているのに、他のサービスはいくつかのHTMLがエスケープされた文字しか許可してなく、"a"を"ä"に置き換えたときに完全にわけの訳の分からないことになってしまうということです。
もし「このプレーンテキストはHTMLがエスケープされたものなのか、それとも単なるプレーンテキストなのか」を考えなければいけない状況になったら、すでに深い問題にとりつかれてしまっています。

フレームワークの選択に無駄に時間をかける

これはおそらく「間違いリスト」の先頭に来るべき話です。あなたが小さなアプリケーション(たとえばコードが10000行以下の物)を作ろうとしている場合、フレームワークはなんであろうが問題はありません。それ以上のコード行数がある場合でも、システムを切り替えるのは本気を出せばたいしたことではありません。
実際O/Rマッパのようなコアコンポーネントを切り替えることも可能で、1つずつやっていけばいいだけのことなのです。あなたの時間を効率的に使うことが、システムを良くするのです。フレームワークの選択はシステムに互換性がなかった頃は非常に大変なことでしたが、今の時代それはもはや問題ではありません。

実際はこの問題と次のトピックはセットになります。

モノリシックなシステムを構築する

私たちはアジャイルな世界に生きています。システムが作り終わる前にクローンが作られてしまうこともあります:-) そんなアジャイルな世界では、新しい技術が、それがまだサポートされていない、あなたの大好きなプラットフォーム上にものすごい速さで移植されます。

Web開発者としては、システムを切り分ける素晴らしいプロトコルがあるということは非常に大きなアドバンテージになっています:そのプロトコルはHTTPと呼ばれていて、Web開発をする上で基礎となっているものです。これをもっと利用しない手はないでしょう。HTTPを話す小さいサービスを書いて他のアプリケーションとのブリッジするのです。スケールしないのなら個々のコンポーネント間にロードバランサを置きましょう。
これはシステムの個々のパートが異なったシステム上に実装できるという良い副作用をもたらします。もしPythonが必要なライブラリを持っていなかったり、パフォーマンスが良くないのであればその部分をRubyJavaあるいは何でも思いつくものでシステムを実装すればいいんです。

しかしシステムのデプロイのしやすさと他のマシンへの移行のしやすさだけには気を遣うようにしてください。もしシステムが最終的に異なる10個のプログラミング言語と異なるランタイム環境で動作するようなものになってしまった場合、システムアドミニストレータは即座に地獄を見る羽目になるのです。

追記 (2010.12.26 0:35)

typo修正とリンク先の修正をしました。

*1:Arminさんに自己紹介もお願いしたのですが間に合いませんでした。