ベースマキナはどうやってデータソースとの接続の安全性を担保しているのか ~ bridge


先日、機密性の高い自社データを取り扱う社内システムを立ち上げるローコードサービス『BaseMachina(ベースマキナ)』の公開版提供を開始しました。プレスリリースはこちらからご覧いただけます。

この記事ではベースマキナが機密性の高いデータを保護しつつ、どのようにデータソース(アプリケーションの API や DB)との接続を行うのかを紹介できればと思います。

既存サービスの問題点

Retool のような会社の内製ツールビルダーサービス(以下 SaaS)は既にいくつか存在します。

これらのほとんどが利用する企業アカウントに対してデータソースの接続をパブリックに公開し、SaaS が保有するいくつかの IP アドレスからの接続だけを許可してもらうような設定を求めています。この案内は利用者として、いくつかの懸念が生じます。

  • 接続元の SaaS の従業員が簡単にアクセスできてしまうこと
  • SaaS が何かしらハッキングの被害に遭い、ホワイトリストに登録した IP 経由で第三者からの接続ができてしまうこと

ベースマキナではこれらを解決するために bridge を開発しました。

bridge

bridge とはオープンソースとして開発された、ベースマキナ API から企業アカウントが管理しているデータソースへ接続するための認可機能付きのゲートウェイです。データの操作を実行するとベースマキナ API から bridge 経由し、データソースへ命令が伝搬されます。

ベースマキナでも IP アドレスのホワイトリスティングを案内していますが、その上でデータソースへ接続する前段に bridge を置くことも案内しています。設置後それぞれの構成は下記の図の様になります。

bridge-draw

  1. HTTP サーバーとして振る舞います。ベースマキナ API からは HTTP リクエストとして bridge へ接続します。
  2. bridge 側でリクエストが企業アカウントから行われたものかどうかを検証します。
  3. ベースマキナ API とデータソースの通信を中継します。

これらのうち 2, 3 に焦点を当てて紹介します。

リクエストの検証

ベースマキナ API から bridge を経由してデータソースへアクセスする際に、bridge ではそのリクエストがデータソースに対してアクセス権を持つリクエストかどうかを検証します。

API から bridge へリクエストする際に必ず Authorization ヘッダを付与します。それに対して値は JWT が付与されます。ペイロードにはリクエストしたベースマキナにログイン中のユーザー情報や企業アカウントの情報が含まれます。

自分達のネットワーク内に bridge をデプロイする企業は必ず自分のテナント情報を設定する様になっています。また、その bridge はベースマキナ API へ JWT の検証に使う JWK 公開鍵を定期的に取得します。これら 2 つの情報を利用してベースマキナ API からのリクエストが有効なものかどうかを検証します。

通信の中継

2022年3月現在、bridge は HTTP のリバースプロキシと HTTP から TCP 接続へアップグレードしたコネクションを利用したプロキシの役割を担えます。

HTTP のリバースプロキシには net/http/httputil パッケージを利用しています。mercari engineering blog の「Goでproxy serverを作るときにハマるポイント」でも紹介されている通り、このパッケージが提供するリバースプロキシの機能をそのまま利用すると Host ヘッダを書き換えないので自前で書き換えるように処理を少し変更しています。

後者は kazeburo さんのブログ記事「TCP over WebSocket & IAP」の発想を基に開発しました。この部分が面白ポイントだと思うのでもう少し解説したいと思います。

中継する時のコネクションの確立手順は以下になります。

  1. bridge と API (bridge クライアント)のコネクションを確立します (conn1)
  2. bridge からデータソースとのコネクションを確立します (conn2)

2 つのコネクションが確立されたら、それぞれのコネクションの読み書きを io.Copy() を利用して bridge 内で中継します。このようなストリーム処理では必須の関数です。

// conn2 から読み取って conn1 へ書き込み
go io.Copy(conn1, conn2)

// conn1 から読み取って conn2 へ書き込み
go io.Copy(conn2, conn1)

conn1 を bridge server 側で取得する必要があります。

HTTP/1.1 では Upgrade ヘッダーを利用した異なるプロトコルへアップグレードできる特殊な仕組み[1]が提供されています。これを行うことで TCP コネクションを直接扱えるようになります。bridge では接続しているクライアントへ確立済みのコネクションを WebSocket としてへアップグレードすることをクライアントへ伝達しますが、実装上は TCP 接続を行うようにアップグレードされます。[2]

アップグレード後、Go の http パッケージではクライアントからのリクエストに使われている TCP コネクションを取り出すための仕組みとして http.Hijacker インターフェースが提供されています。このインターフェース が持つ Hijack() メソッドを利用すると net.Conn (TCP のコネクション)と、メソッド呼び出し直前にバッファリングされたデータを取り出すために必要な *bufio.ReadWriter が利用できます。次のようなコードを記述することでコネクションに流れてくるデータを完全に読み取ることが可能になります。

conn, buf, err := hijacker.Hijack()
// error handling

// io.Copy へ渡す
bufConn := &bufConn{rawConn: conn, reader: buf.Reader}

// bufConn の定義
type bufConn struct {
    rawConn net.Conn
    reader  *bufio.Reader
}

var _ io.ReadWriter = (*bufConn)(nil)

func (c *bufConn) Read(b []byte) (int, error) {
    if c.reader.Buffered() > 0 {
        // bufio.Reader は byte slice を buffer として持つ。
        // 読み取られてない buffer が存在する時、buffer から Read メソッドに渡ってきた byte slice へ
        // 残っている分をコピーする。
        // この時 bufio.Reader が内部でもつラップされた Reader を使わない。
        return io.MultiReader(c.reader, c.rawConn).Read(b)
    }
    return c.rawConn.Read(b)
}

func (c *bufConn) Write(b []byte) (int, error) {
    return c.rawConn.Write(b)
}

ベースマキナでは Hijack() から取得した net.Conn だけを利用していた時期があり、SQL 接続などでありえないようなエラーが度々発生してました。これは一部のデータが欠損していたことで発生していたことが理由だと明らかになり、上記のような修正したことで一切発生しなくなりました。

最後に

私達が提供するミドルウェアが原因でエラーレートが上がるようなことがあってはいけないと考えているので、ベースマキナのプライベートリポジトリでは沢山のテストケースを追加していたり、負荷テストなども行うコードも用意しています。当たり前のことですが日々品質向上のための努力をしています。

この記事をきっかけに、ベースマキナと bridge を組み合わせて使ってみたい、もう少し詳しく話を聞きたいといった要望があれば気軽にお問い合せmeety でお声かけください!

またオープンソースとして公開しているので何か問題があれば是非 issues へ起票していただけると幸いです。

脚注
  1. https://developer.mozilla.org/ja/docs/Web/HTTP/Protocol_upgrade_mechanism ↩︎

  2. ほとんどの Serverless 環境では bi-directional stream を行う環境として WebSocket プロトコルの利用が許可されているため、レスポンスヘッダとしては WebSocket へアップグレードしたことにしている ↩︎