Python

Python 3 で簡易の HTTPS サーバーを立てる

概要

JavaScript のコードをブラウザーで試すとき Python の HTTP サーバーは手軽な手段として重宝します。2016年から JavaScript のコミュニティで注目されるようになった Progressive Web App (PWA) では HTTPS が必須ですが、Python の HTTPS サーバーおよび Python 3 系での ssl モジュールの変更についての記事が以外となかったので、調べたことをまとめることにしました。

前提

Python 3.6 および OpenSSL 1.0.2 とそれ以降のバージョンを前提としてます。Python 3 系の ssl モジュールにさまざまなメソッドや定数が追加されており、Python 3.6 以前のバージョンではサンプルコードが動かない可能性があります。

OpenSSL のバージョンは ssl の定数から調べることができます。

>>> import ssl
>>> ssl.OPENSSL_VERSION

HTTP サーバーの復習

HTML ファイルを提供するサーバーをコマンドラインから起動させる場合、次のコマンドを実行します。

python3 -m http.server 8000

CGI を利用したい場合、--cgi オプションを指定します。

python3 -m http.server --cgi 8000

実行したいスクリプトは /cgi-bin もしくは /htbin に設置する必要があります。

#!/usr/bin/env python3

print("Content-Type: text/plain; charset=utf-8;\r\n")
print("hello world")

自己署名証明書と秘密鍵の生成

次のワンライナーで自己署名証明書および秘密鍵を生成することができます。

# https://stackoverflow.com/a/41366949/531320
openssl req -x509 -newkey rsa:4096 -sha256 \
-nodes -keyout server.key -out server.crt \
-subj "/CN=example.com" -days 3650

HTTPS サーバーを立てる

http.server モジュールに加えて、ssl モジュールを読み込む必要があります。
SSLContext オブジェクトを生成し、いろいろな設定を指定した上で、SSLContext.wrap_socket を使って SSLSocket オブジェクトを生成します。

server.py
from http.server import HTTPServer, SimpleHTTPRequestHandler
import ssl

def run(host, port, ctx, handler):
    server = HTTPServer((host, port), handler)
    server.socket = ctx.wrap_socket(server.socket)
    print('Server Starts - %s:%s' % (host, port))

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    server.server_close()
    print('Server Stops - %s:%s' % (host, port))

if __name__ == '__main__':
    host = 'localhost'
    port = 8000

    ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
    ctx.load_cert_chain('server.crt', keyfile='server.key')
    ctx.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
    handler = SimpleHTTPRequestHandler

    run(host, port, ctx, handler)

ssl.OP_NO_TLSv1ssl.OP_NO_TLSv1_1 はそれぞれ TLS 1.0、TLS 1.1 を無効にすることを意味します。

CGI に対応する

CGI の場合、SimpleHTTPRequestHandler の代わりに CGIHTTPRequestHandler を使います。

server.py
from http.server import HTTPServer, CGIHTTPRequestHandler
import ssl

def run(host, port, ctx, handler):
    server = HTTPServer((host, port), handler)
    server.socket = ctx.wrap_socket(server.socket)
    print('Server Starts - %s:%s' % (host, port))

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    server.server_close()
    print('Server Stops - %s:%s' % (host, port))

if __name__ == '__main__':
    host = 'localhost'
    port = 8000

    ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
    ctx.load_cert_chain('server.crt', keyfile='server.key')
    ctx.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1

    handler = CGIHTTPRequestHandler
    handler.cgi_directories = ['/cgi-bin', '/htbin']
    # https://stackoverflow.com/a/27303995/531320
    handler.have_fork=False

    run(host, port, ctx, handler)

macOS および Unix の場合、handler.have_fork=False を追加しないとサーバーが動きません。

CGI のメリットは Python 以外の言語のプログラムを動かすことができることです。Node.js のコードは次のようになります。

node.cgi
#!/usr/bin/env node

console.log("Content-type: text/plain; charset=utf-8\n");
console.log("Hello World");

WSGI に対応する

wsgiref モジュールを使えば WSGI に対応したサーバーを立てることができます。

server.py
from wsgiref.simple_server import make_server
from pathlib import Path
import ssl

def simple_app(env, start_response):
    info = env['PATH_INFO'][1:]

    if info == '':
        info = 'index.html'

    root = Path.cwd()
    path = root.joinpath(info).resolve()

    if root in path.parents and path.is_file():
        status = '200 OK'
        body = path.read_bytes()
        suffix = path.suffix
        content_type = {
          '.html': 'text/html',
          '.txt': 'text/plain',
          '.json': 'application/json'
        }.get(suffix, 'text/plain')
    else :
        body = b'404 Not Found'
        status = '404 Not Found'
        content_type = 'text/plain'

    headers = [
       ('Content-Type', content_type),
       ('Content-Length', str(len(body)))
    ]

    start_response(status, headers)
    return [body]

def run(host, port, ctx, app):
    server = make_server(host, port, app)
    server.socket = ctx.wrap_socket(server.socket)
    print('Server Starts - %s:%s' % (host, port))

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    server.server_close()
    print('Server Stops - %s:%s' % (host, port))

if __name__ == '__main__':
    host = 'localhost'
    port = 8000
    ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
    ctx.load_cert_chain('server.crt', keyfile='server.key')
    ctx.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1

    run(host, port, ctx, simple_app)

定義されているすべての環境変数 (env) が知りたいのであれば、PEP-333 をご参照ください。wsgiref.simple_server.demo_app を実行アプリにしたときに表示されるページから確認することもできます。

SSLContext

生成

一般的な使い方では ssl.create_default_context が推奨されています。

ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)

指定できる定数は Purpose.SERVER_AUTH (クライアントサイドのソケットを作るのに使う)と Purpose.CLIENT_AUTH (サーバサイドのソケットを作るのに使う) です。

SSLContext のコンストラクタを直接使うこともできます。

ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)

指定できる定数は PROTOCOL_TLS (デフォルト)、PROTOCOL_TLS_CLIENTPROTOCOL_TLS_SERVER です。Python 3.6 以前で使えたさまざまな定数は撤廃になりました。

オプション

options を通して、設定の確認や追加ができます。デフォルトのオプションは次のように確認できます。

>>> import ssl
>>> ssl.create_default_context().options
<Options.OP_ALL|OP_NO_SSLv3|OP_NO_SSLv2|
OP_CIPHER_SERVER_PREFERENCE|
OP_SINGLE_DH_USE|OP_SINGLE_ECDH_USE|
OP_NO_COMPRESSION: 2203714559>

次のコードでも同じ結果になります。

>>> ssl.SSLContext().options

Python 3.6 のデフォルトオプションは次のとおりです。

  • OP_NO_COMPRESSION SSL チャネルでの圧縮を無効にする
  • OP_CIPHER_SERVER_PREFERENCE クライアントよりもサーバーの暗号リストを優先する
  • OP_SINGLE_DH_USE SL セッションを区別するのに同じ DH 鍵を再利用しない
  • OP_SINGLE_ECDH_USE SL セッションを区別するのに同じ ECDH 鍵を再利用しない
  • OP_NO_SSLv2 SSLv2 接続を禁止
  • OP_NO_SSLv3 SSLv3 接続を禁止

wrap_socket でソケットオブジェクトをラップした後でコンテキストのオプションにアクセスしたり、変更することができます。

server = HTTPServer((host, port), handler)
server.socket = ctx.wrap_socket(server.socket)
print(server.socket.context.options)

2つの wrap_socket の違い

ssl.wrap_socketSSLContext.wrap_socket は指定できる引数が異なります。Python 3.6 での引数は次のとおりです。

ssl.wrap_socket(
  sock,
  keyfile=None,
  certfile=None,
  server_side=False,
  cert_reqs=CERT_NONE,
  ssl_version={see docs},
  ca_certs=None,
  do_handshake_on_connect=True,
  suppress_ragged_eofs=True,
  ciphers=None
)

SSLContext.wrap_socket(
  sock,
  server_side=False,
  do_handshake_on_connect=True,
  suppress_ragged_eofs=True,
  server_hostname=None,
  session=None
)

2つの違いは SSLContext.wrap_socket のほうが指定できるオプションが少ないことです。Python 3.6 で SSLContext.wrap_socket に追加された sessionssl.wrap_socket では指定できません。

ssl.wrap_socketSSLContext.wrap_socket の使いわけの基準としては設定の数が多いか少ないかでしょう。

ssl.wrap.socket を使う場合、設定は server.socket.context を通して変更することになるので、設定の数が多いとコードが読みづらくなります。

SSLContext のオブジェクトを直接つくる別のメリットはサードパーティの HTTP ライブラリに流用できることです。

暗号スイート

暗号スイート (Ciphersuites) は get_ciphers で確認できます。暗号スイートを変更するには set_ciphers を使います。

>>> import ssl
>>> ctx = ssl.create_default_context().get_ciphers()
>>> ctx.set_ciphers('ECDHE+AESGCM:!ECDSA')
>>> ctx.get_ciphers()

Mozilla のサイトでおすすめの暗号スイートが公開されています。TLS 1.3 に関して Cloudflare が nginx の設定ファイルを公開しています。OpenSSL 1.1.0 が TLS 1.3 の一部に対応しており、OpenSSL 1.1.1 で完全に対応する予定です。

その他

TLS の最小バージョン

クレジットカードの決済サービスに採用される PCI DSS v3.2 は2018年6月末日まで SSL および TSL 1.0 を無効にすることを求めています。PyPI に採用されている CDN サービスの fastly は TLS 1.1 も無効にします。

システムに導入されている Python 3 がサポートする TLS のバージョンは次のワンライナーで調べることができます。

# https://news.ycombinator.com/item?id=13539034
python3 -c "import json, urllib.request; print(json.loads(urllib.request.urlopen('https://www.howsmyssl.com/a/check').read().decode('UTF-8'))['tls_version'])"

TLS のための新しい API の提案

PEP 543 は TLS のための新しい API の導入を提案しています。開発の動機として、ssl モジュールは OpenSSL 依存しているために新しい OpenSSL を導入するには Python を再コンパイルしなければならないことや OpenSSL とは異なるさまざまな TLS ライブラリに切り換えることができないことが挙げられています。

主な SSL/TLS ライブラリは次のとおりです。

  • Schannel SSP - Windows
  • Secure Transport - macOS
  • BoringSSL - Android
  • LibreSSL - OpenBSD
  • TLS in Linux kernel (4.1.3)
  • picotls - HTTP/2 サーバーの h2o プロジェクトで開発
  • s2n - Amazon