※ このブログはヌーラバー ブログリレー 2021 23日目の記事です。明日は@hitoshisakumaさんの記事です。
SRE課で主にBacklogに関連する開発をしているEguchiです。
本エントリーではBacklogに実装している情報漏えいを検出・ブロックする機構について紹介します。
ソフトウェアの開発時にはテストやコードレビューを行う場合がほとんどだと思いますが、バグや見落としなどの理由で情報漏えいに繋がる不具合を作り込んでしまう可能性はゼロではありません。そのため、仮にそのようなミスがあったとしても情報漏えいに繋がるようなアクセスをブロックするためにBacklogでは「Inspector」と名付けた機能を導入し多層防御を行なっています。
Backlogについて、 このInspectorを導入する以前の処理フローをアクセス制御に着目して抽象化した図にすると下図のようになります。
このフローでは認証や認可に問題が無かったとしても、実装ミスなどの理由により本来の権限ではアクセスできないはずのデータを読み込み・更新してしまう可能性が潜在しています。
そこで、従来の入力に対する検証に加え、出力に対する検証も行うことでこれらの問題は解決できるのではないかと考え、下図のようにDAOから取り出した直後にそのデータの有効性を検証する「Inspector」を実装しました。これにより、不適切なリクエストを素通りさせてしまったり、仮に意図しないSQLで権限のないデータを取り出そうとしてしても検出・ブロックする事ができます。
Inspectorの実装について
Backlogの基本的なデータ構造ですが、契約ごとにスペースが存在し、その中にユーザー・プロジェクト・課題などの様々なデータが存在しています。あるスペースAのユーザーがスペースを跨いで無関係なスペースBのデータにアクセスすることは許されません。
これらのデータの関係性はスペースをルートとする木構造として表すことができるため、DBから取り出そうとするデータからルートに向かって関係性を遡ることで、そのデータがどのスペースに属するデータか判別することができます。
Inspectorではこの関係性をチェックし、ユーザーが自身の所属しているスペース以外のデータを取り出そうとした時にブロックする、というのが基本的な動作になります。
ここで登場するクラスと主な役割は以下のようになります。言語はScala2.13で、DBアクセスにはScalikeJDBCを利用しています。
DAO関係のクラス
- Record テーブルのレコードに対応するクラス
- IOContext DBセッションを抽象化したクラス
- DaoOnJDBC DAOの基底クラス
- DAO DBにアクセスしレコードを返すクラス
Inspector関係で追加したクラス
- Inspected どのスペースに属するデータかを辿るためのインターフェイス
- InspectionDao マクロを使ってRecordがInspectedを継承しているかコンパイル時チェックを行うクラス
- Inspector 戻り値のデータにアクセスする権限があるかどうか検証を実行するクラス
実際に使用しているコードより簡略化していますがサンプルコードを紹介します。
Record
case class ProjectRecord(id: Int, spaceId: Int, projectKey: String, name: String) extends SpaceInspected {} case class IssueRecord(id: Int, projectId: Int, summary: String, description: String) extends ProjectInspected
IOContext
DBセッション、SQLの追跡に利用するためのrequestId、そしてDBアクセスを検証するInspectorなど、DBアクセスに必要なコンテキストを保持するクラスです。
trait IOContext { val requestId: String val inspection: Inspector }
DaoOnJDBC
withDBSessionを経由してSQLを実行するとその値を返す前にInspectorで検証を行い、DBから読み込んだデータに問題があれば例外を投げるようになっています。更新処理に関してはトランザクション中の場合、権限のないデータの更新はロールバックされます。
abstract class DaoOnJDBC extends LazyLogging { import scala.language.experimental.macros import scala.language.reflectiveCalls protected def withDBSession[A](io: IOContext)(f: DBSession => A): A = macro InspectionDao.withDBSession[A] protected def run[A](io: IOContext)(f: DBSession => A): A = { val res = executeSQL(io)(f) if (!io.inspection.inspect(res)(io)) { val ex = new UnauthorizedOperationException("Unauthorized resource access.") logger.error(s"inspection error.(traceId:${io.requestId}, result:$res)", ex) throw ex } else { res } } protected def executeSQL[A](io: IOContext)(f: DBSession => A): A = { io match { case jdbc: IOContextOnJDBC => f(jdbc.session) case _ => throw new IllegalStateException(s"Illegal context type matched (expected: IOContextOnJDBC, actual: $io)") } } } class IOContextOnJDBC(val requestId: String, val session: DBSession, val inspection: Inspector) extends IOContext class UnauthorizedOperationException(message: String) extends RuntimeException(message) {}
DAO
すべてのDAOでDaoOnJDBCを継承したDAOでwithDBSessionを使用してSQLを発行することをルールとしています。このルールを逸脱したコードを書くとInspectorによる検証を回避出来てしまいますが、現時点ではコードレビューで確認する以外の方法が無いのが課題です。
object ProjectDao extends DaoOnJDBC { import scalikejdbc._ def spaceIdById(id: Int)(implicit io: IOContext): Option[Int] = withDBSession(io) { implicit session => sql"""SELECT SPACE_ID FROM PROJECT WHERE ID = ${id}""" .map(_.int(1)) .single .apply() } def findIdsBySpaceId(id: Int)(implicit io: IOContext): List[Int] = withDBSession(io) { implicit session => sql"""SELECT ID FROM PROJECT WHERE SPACE_ID = ${id}""" .map(_.int(1)) .list .apply() } } object IssueDao extends DaoOnJDBC { import scalikejdbc._ def find(id: Int)(implicit io: IOContext): Option[IssueRecord] = withDBSession(io) { implicit session => sql"""SELECT * FROM ISSUE WHERE ID = ${id}""" .map(rs => IssueRecord(rs.int("id"), rs.int("projectId"), rs.string("summary"), rs.string("description"))) .single .apply() } }
Inspected
スペースの子となるデータ(ProjectRecord)にはSpaceInspected、プロジェクトの子となるデータ(IssueRecord)にはProjectInspectedというように定義します。
sealed trait Inspected {} trait SpaceInspected extends Inspected { val spaceId: Int } trait ProjectInspected extends Inspected { val projectId: Int }
InspectionDao
マクロを使用してコンパイル時に戻り値の型をチェックすることでInspectedを継承していないクラスがあった場合にコンパイルエラーが出るようにすることで、Inspectedの継承を強制するクラスです。
プリミティブ型、数値、日付に関しては漏洩しても影響が小さいことから戻り値として使用することを許可しています。InputStream型で取り出すケースやAkka Streamsを戻り値に利用するケースは対応していないのは今後の課題です。
import scalikejdbc.DBSession import scala.language.experimental.macros import scala.reflect.macros.blackbox object InspectionDao { def withDBSession[A: c.WeakTypeTag](c: blackbox.Context)(io: c.Expr[IOContext])(f: c.Expr[DBSession => A]): c.Expr[A] = { import c.universe._ def typeCheck(t: c.Type): Boolean = { if (t == typeOf[AnyVal] || t == typeOf[Byte] || t == typeOf[Char] || t == typeOf[Short] || t == typeOf[Int] || t == typeOf[Long] || t == typeOf[Boolean]) { true } else if (t == typeOf[java.time.ZonedDateTime] || t == typeOf[scala.math.BigDecimal] || t == typeOf[scala.math.BigInt]) { true } else if (t.baseClasses.contains(typeOf[Inspected].typeSymbol)) { // Subclass of Inspected true } else if (t.typeArgs.exists(typeCheck)) { // Option[Inspected], List[Inspected], etc. true } else { false } } val returnedType = c.weakTypeOf[A] if (typeCheck(returnedType)) { c.Expr(q"""run($io)($f)""") } else { c.abort(c.enclosingPosition, s"${returnedType}にInspected を実装してください。") } } }
Inspector
trait Inspector { def inspect[A](i: A)(implicit io: IOContext): Boolean }
InspectorImpl
Recordが継承しているInspectedの情報を利用してアクセス可能なデータかどうかを実行時に検証するクラスです。DAOからの戻り値に使用する各種コレクションやタプルにも対応しています。ProjectInspected型のデータに対して検証を行う場合、そのprojectIdの値に対応するProjectRecordのspaceIdを取得し、その値が自身のスペースIDと一致しているかどうかを検証します。
戻り値がなんらかのコレクションの場合、すべての要素に対して検証を行いますが安易に実装してしまうとサイズがNのコレクションに対してDBリクエストもN件実行されることになりパフォーマンスの劣化が予想されます。そのため、ProjectInspected型の検証に対しては最初にアクセス可能なすべてのプロジェクトIDを取得することでリクエストを1件にするようにしています。また、 実際のプロダクト中ではこのInspectorImplのインスタンスをユーザー1リクエスト中では使いまわしたり、個々のメソッドをメモ化するなどして極力パフォーマンス影響が出ないように工夫することで、導入前後でのレイテンシ・スループットともに目立った影響を抑えることが出来ました。
import com.typesafe.scalalogging.LazyLogging import scala.collection.immutable final class InspectorImpl(scope: Scope) extends Inspector with LazyLogging { val seqInspector: SeqInspector = new SeqInspector(scope) override def inspect[A](i: A)(implicit io: IOContext): Boolean = { try { doInspect(i) } catch { case e: Throwable => logger.warn("inspector bug", e) true } } private def doInspect[A](i: A)(implicit io: IOContext): Boolean = { i match { case _: Unit | _: Byte | _: Char | _: Short | _: Int | _: Long | _: Boolean => true case _: java.time.ZonedDateTime | _: BigDecimal | _: BigInt => true case i: Inspected => inspect(i) case Some(x) => doInspect(x) case None => true case (a, b) => doInspect(a) && doInspect(b) case map: Map[_, _] => seqInspector.inspect(map.values.toSeq) case iterable: Iterable[_] => seqInspector.inspect(iterable.toSeq) case array: Array[_] => seqInspector.inspect(immutable.ArraySeq.unsafeWrapArray(array)) case _ => logger.debug(s"${i.getClass.getSimpleName} is not inspected.") false } } def inspect(i: Inspected)(implicit io: IOContext): Boolean = { i match { case spaceInspected: SpaceInspected => inspectSpaceId(spaceInspected.spaceId) case projectInspected: ProjectInspected => inspectProjectId(projectInspected.projectId) } } /** * 指定されたデータがスペースにあるデータの場合は true を返します。 */ def inspectSpaceId(spaceId: Int): Boolean = { logger.debug(s"inspectSpaceId(spaceId:$spaceId)") spaceId == scope.spaceId } /** * 指定されたデータがユーザーのアクセス可能な project 配下にあるデータの場合は true を返します。 */ def inspectProjectId(projectId: Int)(implicit io: IOContext): Boolean = { scope match { case spaceScope: SpaceScope => logger.debug(s"inspectProjectId(projectId:$projectId)") ProjectDao.spaceIdById(projectId)(io).contains(spaceScope.spaceId) } } } final class SeqInspector(scope: Scope) extends LazyLogging { private var _accessibleProjectIds: Option[Set[Int]] = None def accessibleProjectIds(implicit io: IOContext): Set[Int] = synchronized { _accessibleProjectIds match { case Some(x) => x case None => val ids = ProjectDao.findIdsBySpaceId(scope.spaceId)(io).toSet _accessibleProjectIds = Some(ids) ids } } def inspect[A](seq: Seq[A])(implicit io: IOContext): Boolean = { if (seq.isEmpty) { true } else { seq.groupBy(_.getClass).forall(t => inspectSeq(t._2)) } } private def inspectSeq[A](seq: Seq[A])(implicit io: IOContext): Boolean = { val head = seq.head head match { case _: Unit | _: Byte | _: Char | _: Short | _: Int | _: Long | _: Boolean => true case _: java.time.ZonedDateTime | _: BigDecimal | _: BigInt => true case _: Iterable[_] => inspect(seq.asInstanceOf[Seq[Iterable[_]]].flatten) case (_, _) => val (a, b) = seq.asInstanceOf[Seq[(_, _)]].unzip inspect(a) && inspect(b) case i: Inspected => i match { case _: SpaceInspected => inspectSpaceId(seq.asInstanceOf[Seq[SpaceInspected]].map(_.spaceId).distinct) case _: ProjectInspected => inspectProjectId(seq.asInstanceOf[Seq[ProjectInspected]].map(_.projectId).distinct, io) } case _ => logger.debug(s"Seq[${head.getClass.getSimpleName}] is not inspected.") false } } /** * 指定されたデータが所属しているスペース配下にあるデータの場合は true を返します。 */ def inspectSpaceId(spaceId: Seq[Int]): Boolean = { logger.debug(s"inspectSpaceId(spaceId:$spaceId)") spaceId.forall(_ == scope.spaceId) } /** * 指定されたデータが所属しているスペース配下にあるデータの場合は true を返します。 */ def inspectProjectId(projectIds: Seq[Int], io: IOContext): Boolean = { logger.debug(s"inspectProjectId(projectIds:$projectIds)") projectIds.forall(accessibleProjectIds(io).contains) } } private sealed abstract class Scope { val spaceId: Int } private case class SpaceScope(spaceId: Int) extends Scope
まとめ
以上、Backlogに実装している情報漏えいを検出・ブロックする機能について紹介しました。 これからも安心してBacklogをご利用いただけるようチームを挙げて努めてまいりますので、よろしくお願いいたします。