Let's Encryptはドメイン認証証明書を無料で発行してくれるたいへん素晴らしいサービスです。ウェブサイトをHTTPSで提供するためには証明書が必要ですが、Let's Encryptの登場以前は認証局から有料で証明書を発行してもらうのが主流でした。それを無料で発行してもらえるのは大変ありがたいことです。また、発行プロセスは自動化されておりとても簡単です。筆者も個人のウェブサイトは全てLet's Encryptで証明書を取得しています。
ところが、Let's Encryptが発行する無料の証明書なんて信頼できないという教義を信奉するタイプの人々も存在するようです。筆者は最近Twitterで見かけました。ということで、そのような思想を持つ方も安心してインターネットを利用できるように、Let's Encryptによって発行された証明書を使用しているウェブサイトのみブロックするプロキシサーバーを作りました。
完成品はこちらです。→ https://github.com/uhyo/proxy-for-mammonists
ソースコードをクローンし、npm install
で依存関係をダウンロード後にnpm start
でプロキシサーバーを起動し、お使いのブラウザで127.0.0.1:8111
をプロキシサーバーに指定しましょう。
動作の様子
今回はFirefoxにこのプロキシサーバーを設定して試してみました。
まず、Let's Encryptを使用していないqiita.comにアクセスしてみると、ちゃんと表示されます。
プロキシサーバーのログを見ると、どのサーバー名へのレスポンスをプロキシしたのかがログに残されています。
次に、筆者のウェブサイト uhy.ooo にアクセスしてみます。このサイトは証明書としてLet's Encryptが発行したものを使用しています。
すると、プロキシサーバーが接続を拒否した旨のメッセージが出ます。
プロキシサーバーのログにはuhy.oooへの接続をブロックした旨のログが残されています。
これで、Let's Encryptを利用する危険なウェブサイトにアクセスしてしまうのを事前に防ぐことができました。とても安全で嬉しいですね。
実装解説
こういう記事はちゃんと実装を解説しないと叩かれるので、ここからはこのプロキシサーバーを解説します。今回はnode.jsを用いて、サードパーティのライブラリは用いずに実装しました。
全体のコードを一瞥したい方は前述のGitHubをご参照ください。ここでは順を追って解説します。
まずHTTPサーバーを建てる
プロキシサーバーは、基本的にはHTTPサーバーです。よって、まずnode.js標準のhttp
モジュールを使用してHTTPサーバーを立てます。
import http from "http";
import net from "net";
import tls from "tls";
const server = http.createServer((req, res) => {
// HTTP リクエストをプロキシする処理
const u = new URL(req.url, `http://${req.headers.host}`);
console.log(`proxying HTTP request to ${req.headers.host}`);
const proxyReq = http.request(
u,
{
method: req.method,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
}
);
req.pipe(proxyReq);
});
このように、http.createServerを用いてHTTPサーバーを作成します。コールバックの(req, res)=> { ... }
という関数はrequest
イベントのハンドラとして登録される関数で、すなわちHTTPリクエストが来るたびに呼び出される関数です。
この中身は、HTTPリクエストをプロキシする処理となっています(HTTPSはまた別です)。これはプロキシサーバーなので、クライアントからの全てのサイトへのHTTPリクエストがこのサーバーへのHTTPリクエストとなります。よって、プロキシサーバーがすべきことは、指定されたサイトへのHTTPリクエストを発行し、向こうから送られてきたデータをそのままクライアントに渡すことです。
そのために、http.request
によりプロキシサーバーから実際のウェブサイトへのHTTPリクエストを作成します。今回node.jsのHTTPサーバー機能を使っているので、ヘッダと本文は別々に処理する必要があります。http.request
のコールバックはサーバーからヘッダが返ってきた(ステータスコードとヘッダ一覧が確定した)段階で呼び出されますので、ここでステータスコードとヘッダをそのままプロキシサーバーからクライアントに送ります。レスポンスの本文も全て仲介するべきですが、それはリクエストやレスポンスがそれぞれReadableストリーム・Writableストリームであることを利用してpipe
メソッドにより行なっています。
ここでは特にLet's Encryptをブロックする処理は入っていません。HTTPは暗号化されていない接続なのでそもそも証明書は登場せず、Let's Encryptが出る幕がないからです。今回作成したプロキシでは、HTTP接続はLet's Encryptを使っていないので安全とみなしてアクセスを許します。
HTTPSリクエストをプロキシする
以上でHTTPリクエストはプロキシできましたが、本命はHTTPSの方です。クライアントがプロキシサーバーを介してHTTPSリクエストを行いたい場合はHTTPとは方式が異なります。HTTPの場合は平文なのでプロキシサーバーがHTTPを喋りましたが、暗号化された接続の場合は中間者であるプロキシサーバーが通信の中身に介入することができません。そのため、プロキシサーバーはHTTPではなくTCPのレベルのトンネルとなり接続を仲介します。
この場合、クライアントはプロキシサーバーに対してCONNECTメソッドのリクエストを行います。CONNECTメソッドの説明はMDNにも書いてありますね。
このリクエストには、クライアントがどのホスト名に接続したいかという情報が含まれています。プロキシサーバーが了承した場合、以後その接続はトンネルとして機能することになります。
今回のプロキシサーバーは接続先のサーバーがLet's Encryptの証明書を使用しているかどうかチェックしなければなりません。今回は、実際にプロキシサーバーからTLS接続を張ってみてみて証明書情報を取得するという方法を採っています。当該部分のソースコードはこの通りです。
function checkCertificateIsNotLetsEncrypt(host, port) {
return new Promise((resolve, reject) => {
const checkConn = tls.connect(
{
host,
port,
servername: host,
},
() => {
const cert = checkConn.getPeerCertificate();
const issuer = cert && cert.issuer;
if (issuer) {
if (issuer.O === "Let's Encrypt") {
// this should be blocked
resolve(false);
} else {
// OK
resolve(true);
}
} else {
// no issuer?
reject(new Error("Could not get certificate issuer"));
}
}
);
checkConn.on("error", reject);
});
}
tls.connect
で接続先サーバーとのTLSコネクションを張り、成功したらcheckConn.getPeerCertificate()
で証明書情報を得ています。今回はissuerのOフィールドがLet's Encrypt
だったらLet's Encryptが発行した証明書と判断するという実装です。
この方法は簡単ですが、実際のリクエストとは別にもう一つコネクションが張られるという問題があります。別のやり方として、プロキシされたTLS接続の通信の中身を見れば証明書の情報が得られるかもしれませんが1、大変そうなので今回それはやりませんでした。
さて、実際のHTTPSプロキシ処理は以下のようになっています。node.jsのHTTPサーバーでは、クライアントがCONNECTメソッドの要求を送ってきたら専用のconnect
イベントが発火するようになっていますから、これをハンドルすることでCONNECTメソッドに応答できます。
server.on("connect", (req, socket, head) => {
console.log(`proxying HTTPS request to ${req.headers.host}`);
const u = new URL(`https://${req.headers.host}`);
const certificateCheckP = checkCertificateIsNotLetsEncrypt(
u.hostname,
u.port || 443
);
const conn = net.createConnection(
{
host: u.hostname,
port: u.port || 443,
},
() => {
certificateCheckP
.then((ok) => {
if (ok) {
socket.write("HTTP/1.1 200 OK\n\n");
conn.pipe(socket);
} else {
console.log(`Request to ${u.hostname} is blocked`);
socket.end("HTTP/1.1 403 Forbidden\n\n");
}
})
.catch((err) => {
// ?
socket.end("HTTP/1.1 500 Internal Server Error\n\n" + err.toString());
});
}
);
if (head) {
conn.write(head);
}
socket.pipe(conn);
conn.on("error", (err) => {
console.error(err);
socket.end();
});
});
今回、CONNECTメソッドを受けたプロキシサーバーは実際の接続先に対してnet.createConnection
で接続します。これはTCPのソケットです。今回TLSやHTTPではなくTCPのソケットなのは、TLSハンドシェイクがクライアントと接続先サーバーの間で直接行われるからです。それを仲介する役目を持つプロキシサーバーはその下のレベルであるTCPで両者をトンネルしなければいけません。
CONNECTメソッドを受けたプロキシサーバーは、接続先がLet's Encryptを使用しているかどうかチェックするcertificateCheckP
の結果を持って、使用していない(接続を許可する)のであればクライアントにHTTP/1.1 200 OK\n\n
を返します。この直後からクライアントに対しては接続先からのデータが直接渡されることになります(このことはRFC 7231に明記されているのでそれに従いました。ステータスコード200台を返せば何でもいいようです)。接続を許可しない場合は403を返しています。
成功した場合、200をクライアントに送った直後から接続先サーバーのデータをクライアントへパイプします。また、クライアントからのデータは最初から接続先サーバーにパイプされています。これによりトンネルが完成し、HTTPS通信を仲介できました。
まとめ
これでLet's Encryptを信頼しない教義の方も安心ですね。
-
TLSに詳しくないので、本当にできるかどうかは未確認です。 ↩