OTOBANK Engineering Blog

オトバンクはコンテンツが大好きなエンジニアを募集しています!

APIが不必要にセンシティブなデータを返していないことをCIで担保する

暑い日が続きますね。こんにちは @kalibora です。

よその会社であったとしても、セキュリティ関連の事故を見聞きするたびにプログラマーとしては胃が痛くなるのではないでしょうか。

はたして自分のところは大丈夫だろうかと。完璧なんてありえないし、どこかでうっかりミスをして大事故を起こさないだろうかと。

さて、最近では noteでのIPアドレス漏れ のようなことがありましたし、過去には 狙われた7pay「外部ID連携」の脆弱性の全貌。急遽“遮断”した理由 | Business Insider Japan のような問題もありました。

どちらも詳細な原因は外部の私からは知ることはできませんが、APIから不必要なレスポンスを返してしまうことによって起きるセキュリティ関連の事故というのは、わりとよくあるケースなのかもしれません。

私の担当するシステムとしてもこのようなことが起きないか、なにか今以上に対策できることはないか考えてみました。

そもそも何が問題か?

認証のない公開されたAPI(ここでいう公開とは一般に広く仕様を公開しているか否かではなく、認証がなく誰からでもアクセスされる可能性のあるものを指します)というのはWebサーバーがhtmlを返すことと同じなので、 Webページで表示できるものと同じものしか返してはいけない。というのが大前提です。

ですので、公開APIで不必要にセンシティブなデータを返してはいないか?をチェックできればよさそうです。

弊社システムの場合

弊社システムではAPIの仕様(ドキュメント)の生成にOpenAPI(Swagger)を使っています。

この仕様にはAPIのパスごとに認証の有無や返却するレスポンスの定義が含まれているので、 これをしっかりとレビューしていれば問題なさそうです。

また、このOpenAPIの仕様を書くにあたって、Swagger Editor 等のエディターを使うのではなく、 zircote/swagger-php: A php swagger annotation and parsing library を用いて、 ソースコード中のエンティティやバリューオブジェクトへ annotation を付与することで、OpenAPIの仕様を書いています。

さらに、独自実装したシリアライザーを用い、これと同じ annotation を読み取ってレスポンスのjsonを返す。 といったこともしています。

これにより、OpenAPIで記述した仕様と実際に返却されるAPIのレスポンスに乖離が起きることがないため、 やはりOpenAPIの仕様をきちんとレビューすればこのような事故を防ぐことができそうです。

このあたりの詳細については下記のスライドを参照ください。 speakerdeck.com

仕様をチェックするだけなら機械的にレビューできる

OpenAPIの仕様はyamlかjsonで記述されています。ということは、これをプログラムで読み取ることで機械的にレビューできそうです。

例示してみます。

下記のOpenAPI仕様には、オーディオブック取得と自分自身のユーザー情報を取得する2つのAPIがあります。(分かりやすいようにyaml中にコメントを入れています)

paths:
  /audiobooks/{audiobookId}:
    get:
      summary: "オーディオブック取得API"
      parameters:
      - name: "audiobookId"
        in: "path"
        required: true
        type: "integer"
        format: "int64"
      # レスポンスはdefinitionsに定義したAudiobookを返すということが分かる
      responses:
        "200":
          schema:
            $ref: "#/definitions/Audiobook"
  /users/me:
    get:
      summary: "自分自身のユーザー情報取得API"
      # レスポンスはdefinitionsに定義したUserを返すということが分かる
      responses:
        "200":
          schema:
            $ref: "#/definitions/User"
      # OAuth認証のあるAPIだということが分かる
      security:
      - oauth: ['dummy']

definitions:
  # オーディオブック情報の定義(タイトルや説明文は公開情報なので、これらは一般公開してよい)
  Audiobook:
    type: "object"
    properties:
      id:
        type: "integer"
        format: "int64"
      title:
        type: "string"
        description: "オーディオブックのタイトル"
      description:
        type: "string"
        description: "説明文"
  # ユーザー情報の定義(メールアドレスがあったり、基本的に他人からは知られたくない情報)
  User:
    type: "object"
    properties:
      id:
        type: "integer"
        format: "int64"
      name:
        type: "string"
        description: "ユーザー名"
      email:
        type: "string"
        description: "メールアドレス"

/audiobooks/{audiobookId} のオーディオブック取得APIは、認証がないので公開APIですが、レスポンスのAudiobookが公開できる情報なので問題ありません。

対して、

/users/me のユーザー情報取得APIが返すレスポンスはメールアドレスがあったりと、一般公開するものではないですが、OAuth認証によって自分自身しか取得できないようになっているため、これもまた問題ありません。(メールアドレスなどの個人情報といえども、APIとして返せないとWebやアプリで表示することは出来ないので、レスポンスに露出していること自体がダメなわけではなく、認証せずに他人の情報が見れることが問題)

ここで、オーディオブック取得APIの仕様が拡張され、下記のようになったらどうでしょうか?

paths:
  /audiobooks/{audiobookId}:
    get:
      summary: "オーディオブック取得API"
      parameters:
      - name: "audiobookId"
        in: "path"
        required: true
        type: "integer"
        format: "int64"
      responses:
        "200":
          schema:
            $ref: "#/definitions/Audiobook"
definitions:
  Audiobook:
    type: "object"
    properties:
      id:
        type: "integer"
        format: "int64"
      title:
        type: "string"
        description: "オーディオブックのタイトル"
      description:
        type: "string"
        description: "説明文"
      # このオーディオブックを最後に購入したユーザーを返すようになった
      latestPurchasedUser:
        $ref: "#/definitions/User"

オーディオブック取得APIは公開APIであるにも関わらず、レスポンスのオーディオブック内の latestPurchasedUser がユーザーを返すようになったので、ユーザーのメールアドレスや名前などが露出してしまいます。これは問題です。

レビューで仕様をじっくり確認していればこのようなことは防げますが、プログラムで簡単にチェックすることもできます。

今回の場合はそもそも definitions に定義された User が公開APIのレスポンスに現れること自体がまずそうなので、

このyamlファイルをパースし、 $ref は適宜再帰などを用いて解決し、認証のないAPIで User がレスポンスに現れることがないかをチェックすれば問題なさそうです。

他にも properties に特定のキー(passwordなど)が含まれていたらエラーにすることも簡単にできそうです。

ということで、弊社でも早速その様なスクリプトを書いてCIに組み込むことにしました。

汎用性のないソースコードなので特に公開はしませんが、200行くらいの簡易なスクリプトでチェックすることができるようになりました。

まとめ

  • 認証のない公開されたAPIはWebと同じ様に扱い、センシティブなデータが返却されないようにすること
  • オトバンクではAPIの仕様と実装に乖離がないように保たれているので、APIの仕様をきちんとレビューすればよかった
  • APIの仕様をレビューするだけであればすべてのAPIに対しテストを書かずとも、機械的にレビューすることができ、CIに組み込むことが出来た

GraphQLなど他の形式であってもAPIのスキーマを定義しているシステムなら同じようなことができると思うので、みなさまもちょっとしたスクリプトを書いて安心感を増やしてみてはいかがでしょうか。

わたしのReact Hooksの使い方

こんにちは、アプリ開発担当のエモトです。Pixel 4a が発表されましたね。私のメイン機は iOS なのですが、Android も多少持っているので興味があります。パンチホール式を採用した広々としたディスプレイに惹かれるモノがありますが、「今は時期が悪い、5Gまで待て」と言い聞かせ、5Gモデルを首長く待ちます。まあ、私が使っている回線はMVNOなので、5Gが使えるのがいつになるかは分かりませんが。5Gの風を浴びたい

初めて React Hooks の存在を知ったとき、まだ React Native もよく分かっていない時期で、「フックス?なにそれ?おいしいの?」な状態でした。開発を幾つかやってきて慣れてきた現在は、Hooks で全部書きたいと、思うようにもなりました。

私の開発内容では、標準の Hooks で十分に開発はできているのですが、ときどき少し足りないと思う時があり、その場合はカスタム Hooks を使っています。今回はそれを少し紹介します。

didMount/didUnmount時のみに呼ばれるuseEffect

カスタム Hook を用意せずとも、useEffect の第二引数に [] を指定すれば、実現できます。追加するほどではないと思っていたのですが、[] を指定するたびに ESLint が反応するのは面倒なため、追加しました。

export function useEffectOnce(effect: EffectCallback) {
  useEffect(effect, [])
}

第二引数を書く必要なくなるので、コードがすっきりしまた。これは有名なカスタム Hook の1つであり、カスタム Hook を扱った技術ブログやライブラリでもしばしば見かけます。

State変数の以前の値を参照したい

これも有名なカスタム Hook です。変数の変化量に応じて処理や表示の場合分けが必要なとき、変数更新のたびに、以前の値を保存するためのコードを挿入する必要がないので、とても便利です。

export function usePrevious<T>(value: T) {
  const ref = useRef<T>(null)
  useEffect(() => {
    ref.current = value
  })
  return ref.current
}

Stateではない変数を Hook で管理したい

変数管理は setState を用いれば、変数の参照や更新もできるので便利です。ただし、対象の変数が component に影響しない(させたくない)場合は、変数更新のたびに起こる不要な再レンダリングでパフォーマンスに影響するため、不向きです。State のためにあるのだから、当然のことです。それでも、setState の変数管理だけができないかと色々と考えました。

すぐ思いつくのは、useRef を使うことです。

const ref = useRef(false)

ref.current = hogehoge

if (ref.current) {
  piyopiyo()
}

しかし、コード中に current が唐突に出てくるのは違和感を感じます。そこで、それを改善する Hook を作成しました。

  • カスタム Hook
/**
 * ステートレスな変数を管理する
 */
export function useStateless<T>(value: T): [() => T, (v: T) => void] {
  const ref = useRef<T>(value)
  return [
    () => {
      return ref.current
    },
    (v: T) => {
      ref.current = v
    },
  ]
}
  • 使用例
const [getRef, setRef] = useStateless(false)

setRef(hogehoge)

if (getRef()) {
  piyopiyo()
}

setState と異なって、変数参照が関数になってしまったのが、残念なところです。内部に useEffect を入れて、値を更新することができなかいかと試しましたが、うまく行かず、このようになりました。しかしながら、Hook でステートレスな変数を管理できるようになったので、コードが整理されたと思っています。

まとめ

Hooks は公式でカスタマイズが紹介され、またサードライブラリが公開さてします。それらを利用して、より便利に開発しましょう。

以前は、React Native の開発に躓くことが多かったのですが、function component そして React Hooks を使えるようになってから、自分が書きたいコードを自分で書けるようになってきたと思います(まだまだ知らないことは多いですが)。React Hooks は、私が React Native 開発がより面白いと思った起点の1つでもあるので、React Hooks を見ていくのは React Native の勉強におすすめです。

最後に、オトバンクではエンジニアを募集中です。オーディオブックや、React Native 開発(Swift や Kotlinのネイティブコードも書いてます)にご興味があれば、是非どうぞ。

お待ちしております。

React NativeアプリでSpotlightを使った検索を実装する

こんにちは、アプリ開発担当のエモトです。みなさま給付金は受給されましたか?僕は、あ、あれ、何かノートパソコンが生えてる。不思議なこともあるもんだな。ちなみ、前回の給付金(リーマンショックを受けて、2009年に施行)では、Realforceを購入して、今それを使って、このブログを書いています。

さて、先日のアップデートで iOS 版オーディオブックアプリは、Spotlight を使ったオーディオブック作品の検索に対応しました。Spotlight とは?アプリではどう使われる?と気になる方は、弊社 note に紹介記事「Spotlightで検索できるようになりました」 が投稿されています。是非ご覧ください。弊ブログでは技術面を少し話したいと思います。

当初、React Native のサードライブラリを利用して、Spotlight の実装をしようを思っていました。しかしながら、我々の想定にあうライブラリが見当たらなかったので、自作することにしました。もちろん、ブログ3回目の登場、Native Module を使います。以前、紹介した Siri Media Intents や Sign in with Apple とは異なり、Spotlight は iOS 8 に登場して機能です。前者と比べると複雑ではないので、比較的簡単に実装できました。

実装

コード全文を紹介すると長くなるので、エッセンシャルを紹介します。Native Modules の基本的な使い方は今回省略しています。

Spotlightの実装

Spotlitght を使用するには、まず最初に、検索対象となる SearchableItem 型の検索データを用意します。React Native での使用を想定しているので、検索データは連想配列(辞書型)で与えられるとしています。

// import Foundation
// import CoreSpotlight
// import Alamofire
// import AlamofireImage

fileprivate class SearchItemKey {
    static let title = "title"
    static let description = "description"
    static let keywords = "keywords"
    static let imageUrl = "imageUrl"
    static let domain = "domain"
    static let id = "id"
}

/// create dictionary to CSSearchableItem
func createSearchableItem(item: [String: Any],
                          completion: @escaping  (CSSearchableItem) -> () ){
  
    var domain = Bundle.main.bundleIdentifier
    if let d = item[SearchItemKey.domain] as? String{
        domain = d
    }
        
    let attr = CSSearchableItemAttributeSet.init(itemContentType: kCIAttributeTypeImage)
    attr.title = item[SearchItemKey.title] as? String
    attr.contentDescription = item[SearchItemKey.description] as? String
    attr.keywords = item[SearchItemKey.keywords] as? [String]
    attr.domainIdentifier = domain
     
    // 画像データがない or 直接画像データがある場合はダウンロード処理は不要です
    // その場合は直接 return item して問題ありません   
    let callCalback = { () in
        let item = CSSearchableItem.init(uniqueIdentifier: item[SearchItemKey.id] as? String,
                                         domainIdentifier: domain,
                                         attributeSet: attr)
        completion(item)
    }
    if let imageUrl = item[SearchItemKey.imageUrl] as? String{
        Alamofire.request(imageUrl, method: .get).responseImage { response in
            if let image = response.result.value{
                attr.thumbnailData = image.pngData()
            }
            callCalback()
        }
    }else{
        callCalback()
    }
}

次に登録を行います。登録は簡単で先ほど生成した CSSearchableItem 配列を登録メソッドに与えます。なお、登録があれば、もちろん削除もあります。興味ある方はアップルの開発ドキュメントをご確認ください。

let searchableItems = [CSSearchableItem]()
let searchableIndex = CSSearchableIndex.default()
searchableIndex.indexSearchableItems(searchableItems) { (error) in
    // 完了やerror処理など
}

iOS と React Native の連携

Spoltlight の準備ができました。次は、その Spoltlight のイベントを React Native に送る方法です。Native Modules の RCTEventEmitter を継承したクラスを用意します。

fileprivate enum SearchOnDeviceListener: String, CaseIterable {
  case onSearchOnDeviceRequest
}

@objc(SearchOnDevice)
class SearchOnDevice: RCTEventEmitter {

    @objc public static let shared = SearchOnDevice()

    override init() {
      super.init()
      for listener in SearchOnDeviceListener.allCases {
        self.addListener(listener.rawValue)
      }
    }
    
    deinit {
      self.removeListeners(Double(SearchOnDeviceListener.allCases.count))
    }
    
    @objc
    override func supportedEvents() -> [String]! {
      return SearchOnDeviceListener.allCases.map {
        $0.rawValue
      }
    }
    
    @objc(handle:)
    static public func handle(_ userActivity: NSUserActivity) -> Bool {
        var identifier: String? = nil
        if userActivity.activityType == CSSearchableItemActionType{
            identifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String
        }
        var query: String? = nil
        if userActivity.activityType == CSQueryContinuationActionType {
            // iOS 10以上かつ、plistで設定が必要
            query = userActivity.userInfo?[CSSearchQueryString] as? String
        }
        let obj = SearchOnDevice.shared
        obj.sendEvent(withName: SearchOnDeviceListener.onSearchOnDeviceRequest.rawValue,
                           body: ["id": identifier, "query": query])
    }
}

上記を AppDelegate.m にて Spotlight が実行されると呼ばれるメソッド内で設定します。これにより、実行のたびに、React Native へ検索データが送られます。そのデータを使って、詳細画面や検索画面を開けば OK です。

// AppDelegate.m
- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler
{
  return [SearchOnDevice handle:userActivity];
}

まとめ

開発当初、適切なライブラリが見つからないとアクシデントがありましたが、Spotlight 自体の実装コストが低いのもあり、比較的に容易に実装できました(これまでの Siri Media Intents や Sign in with Apple の Native Module 実装の経験が役立ちました)。Spotlight は低い開発コストのうえ、iPhone の検索からユーザーが Safari に行くことなく自アプリへ導入できるという非常に魅力的な機能なので、おすすめです。

最後に、オトバンクではエンジニアを募集中です。日頃は Swift や Kotlin を書かれてる方でも、オーディオブックや、React Native 開発(ネイティブコードも書いてます)にご興味があれば、是非どうぞ。

お待ちしております。