プププなテクブ

毎週末にJavaScript/TypeScript/AIに関する記事をお届けします

Next.jsはどのようにCSRF対策をしているのか?

最近、Next.jsがどのようにCSRF対策をしているのかを考える機会がありました。

そんななかで色々と学んだことを今回は書きます。

CSRFとは

本題に入っていく前に、一度CSRFについて改めて復習しておきましょう。CSRF(Cross-Site Request Forgery)とは、ユーザーが意図しないリクエストを攻撃者が勝手に送信させる攻撃です。以下に攻撃の一連の流れを可視化しています。

sequenceDiagram
    participant User as ユーザー
    participant Bank as 銀行サイト (bank.example)
    participant Evil as 悪意のあるサイト (evil.example)

    User->>Bank: ログイン(セッションCookieを取得)
    User->>Evil: 悪意のあるページを開く
    Evil->>User: 自動送信フォームを含むHTMLを返す
    User->>Bank: POST /transfer (Cookieが自動付与される)
    Bank->>Bank: セッションCookieが有効 → 振込実行!

悪意あるページevil.exampleが返す自動送信フォームは以下のようなイメージです。送信者のtoの値を攻撃者のものに、金額を任意の(大きな)額にしておきます。

  <body onload="document.forms[0].submit()">
    <form action="https://bank.example/transfer" method="POST">
      <input type="hidden" name="to" value="attacker">
      <input type="hidden" name="amount" value="100000">
    </form>
  </body>

この攻撃が成立するのは、ブラウザの仕様のせいです。MDN HTTP cookies には次のように記載されています。

When a new request is made, the browser usually sends previously stored cookies for the current domain back to the server within a Cookie HTTP header

基本仕様として、ブラウザは同じサーバーへのリクエストにCookieを自動で付与します。そのため、evil.exampleからsubmitする際にも、bank.exampleにログイン済みであればセッションCookieを使ってリクエストが実行されるというわけです。

一方でサーバー側からすると、そのリクエストが正規のページから来たものか、悪意のあるページから来たものか区別できません。

よくあるCSRF対策

上記の通り、CSRF攻撃は サーバー側でリクエストが正規のものか? を検証することができれば成立しません。

この検証方法として長らく使われてきたのが「CSRF Token」を使う方法です。この方法はOWASPのSRF Cheat Sheetや徳丸先生の著書・安全なWebアプリケーションの作り方でも推奨されている方法です。

この方法では以下のようにしてリクエストが正規のページからきたものか?を検証します。

  • サーバー側であらかじめ、推測困難なランダム文字列のTokenを生成する。作成したTokenはセッションIDと紐づけて保存する。
  • このTokenをレスポンスに含める。よくあるのがhiddenフィールドに含める方式です。
  • クライアントからはこのTokenを含めてサーバー側へリクエストを送る
  • 送られてきたTokenとセッションIDを保存済みのそれと比較し一致することを確認する

つまり一時的な合言葉を決めておき、リクエストの際には合言葉を使ってリクエストを検証するというものです。この合言葉を知っているのは、直接サーバーがレスポンスしたクライアントだけです。これはSame-Origin Policy があることで、攻撃者は他人のTokenを取り出せない = 攻撃者のformにはTokenを埋め込む手段がないからです。

sequenceDiagram
    participant User as ユーザー
    participant Server as サーバー
    participant Evil as 悪意のあるサイト

    User->>Server: GETリクエスト(フォームページを取得)
    Server->>User: フォーム + CSRF Token(hidden field)を返す
    User->>Server: POST /transfer + CSRF Token
    Server->>Server: セッションのTokenと比較 → 一致 → 処理実行

    Evil->>User: 自動送信フォームを含むHTMLを返す
    User->>Server: POST /transfer(CSRF Tokenなし)
    Server->>Server: Token 不一致 → リクエスト拒否!

LaravelでのCSRF対策を例に

ここで具体例として、PHPフレームワークのLaravelを見ておくことにします*1。LaravelはToken方式によってCSRF対策をしています。LaravelをチョイスしたのはToken方式による対策をしていることと、自分が過去にLaravelのCSRF対策に関する記事を書いていて説明が楽だからです。

zenn.dev

詳細は過去の自分による上記の記事に譲るとして、ここでは簡潔に話します。

LaravelではBlade*2@csrf と書くだけで、hidden inputとTokenを自動生成してくれます。

<form method="POST" action="/transfer">
    @csrf
    <!-- <input type="hidden" name="_token" value="Token(ランダムな文字列)"> -->
</form>

Tokenはセッション初期化時にランダムな文字列として生成され、サーバー側のセッションファイルに保存されます。初期設定の状態では、セッションIDをファイル名として以下のようにTokenを保存します。

a:4:{s:6:"_token";s:40:"G5FzKXaCYA4w8kdWbftEZMYoglQgD9yPIG9r2zzx";s:9:"_previous";a:1:{s:3:"url";s:29:"http://127.0.0.1:8085/profile";}s:6:"_flash";a:2:{s:3:"old";a:0:{}s:3:"new";a:0:{}}s:50:"login_web_59ba36addc2b2f9401580f014c7f58ea4e30989d";i:1;}

そしてformからのsubmitの際にVerifyCsrfToken ミドルウェアがリクエストを検査し、Tokenが一致しない場合は拒否します。またGET, HEAD, OPTIONSメソッドによるリクエストは検査対象外としています。余談ですがLaravelはhash_equals() を使うことでタイミング攻撃にも耐性を持たせているみたいです。

    /**
     * Determine if the session and input CSRF tokens match.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    protected function tokensMatch($request)
    {
        $token = $this->getTokenFromRequest($request);

        return is_string($request->session()->token()) &&
               is_string($token) &&
               hash_equals($request->session()->token(), $token);
    }
    /**
     * Determine if the HTTP request uses a ‘read’ verb.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    protected function isReading($request)
    {
        return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS']);
    }

CSRFの本質的な問題とは?

ここまでToken方式によるCSRF対策を見てきました。しかしよく考えると、CSRF Token方式では結局何を確認しているのでしょうか?Tokenが一致すること...は本質ではありません。

過去にLaravelのCSRF対策の記事を書いたときは、まさにここで思考が止まっていました。Token方式の対策方法を知っただけで満足していました。

CSRF対策の本質。その答えはJackさんの記事に詳しく書かれています。

blog.jxck.io

先に結論から言うと、CSRFの本質的な問題は「リクエストの出自(どこから来たか)がわからないこと」です。CSRF Token方式はこれを明らかにするための一つの手段に過ぎません*3

また、Cookieをクロスサイトで送ってしまうことも問題です。クロスサイトからのリクエストの際にCookieがなければ、そもそも認証が通らずリクエストが失敗します。

この2点に着目しながら、Jackさんの記事を見ていきます。

令和時代のCSRF対策

Tokenを使わずリクエストの出自を知る

これはOriginヘッダの値を見ることでリクエストの出自を知ることができます。

ブラウザはクロスオリジンリクエストや同一オリジンのPOSTリクエストに Origin ヘッダを自動で付与します。値はリクエストを発生させたページのオリジン(スキーム+ホスト名+ポート)であり、パスは含みません。

MDN: Origin に記載の内容を整理すると、Origin ヘッダは以下のパターンで付与されます。

条件 Origin付与 備考
クロスオリジン × GET/HEAD ただし後述の例外あり
クロスオリジン × POST/PUT/PATCH/DELETE/OPTIONS
同一オリジン × POST/PUT/PATCH/DELETE/OPTIONS GET/HEAD以外
同一オリジン × GET/HEAD 付与されない

つまり最初のCSRF攻撃の例に基づくと、

  • 正規リクエスト
    • Origin: https://bank.example
  • 攻撃リクエスト
    • Origin: https://evil.example

となるため、リクエストの出自を知ることができます。

クロスサイトにCookieを送らない

MDN: SameSite cookies によると、SameSite 属性はクロスサイトリクエスト時のCookie送信を制御します。LaxStrictを設定することで、クロスサイトのPOSTリクエストにはCookieが付与されません。ちなみに最新版Chromeのデフォルト値はLaxです。

リクエストの種類 Strict Lax None
同一サイトからのリクエスト 送信 送信 送信
別サイトからのリンククリック(トップレベルナビゲーション) 送信しない 送信 送信
別サイトからのPOSTフォーム 送信しない 送信しない 送信
別サイトからのfetch/XHR 送信しない 送信しない 送信
別サイトからのiframe内読み込み 送信しない 送信しない 送信

このため、攻撃者のサイトからPOSTしてもCookieが届かないため、認証が通りません*4

令和時代のCSRF対策

Jackさんの記事では次の優先順位が示されています。

  1. POSTメソッドの使用*5
  2. Originヘッダの確認
  3. SameSite Cookieの明示的な設定
  4. Fetch Metadataの確認

1. POSTメソッドの使用

副作用(データの変更・削除など)を伴う操作には必ずPOSTを使います。先の表の通り、GETはクロスサイトからのリクエスト時にOriginが付与されません*6。そのため、読み取り専用として副作用を起こさないことが前提です。

2. Originヘッダの検証

サーバー側でこの値を確認するだけで、出自を直接検証できます。

SameSite Lax/Strict を明示することでクロスオリジンにCookieが送信されません。

4. Fetch Metadata(Sec-Fetch-Site)

Sec-Fetch-Site ヘッダを使うと、リクエストがどこから来たかをより詳細に把握できます。

Sec-Fetch-Site: same-origin  # 同じオリジンから
Sec-Fetch-Site: cross-site   # 別ドメインから
Sec-Fetch-Site: none         # 直接アクセス(URLバーなど)

Next.js 公式ブログで紹介されているアプローチ

前置きが非常に長くなりましたが、ここからが本題です。実は、Next.jsの公式ブログでもNext.jsが上述のアプローチにかなり近い方法でCSRF対策を施していることが紹介されています。

nextjs.org

公式ブログには次のように明記されています。

Behind the scenes, Server Actions are always implemented using POST and only this HTTP method is allowed to invoke them. This alone prevents most CSRF vulnerabilities in modern browsers, particularly due to Same-Site cookies being the default.

As an additional protection Server Actions in Next.js 14 also compares the Origin header to the Host header (or X-Forwarded-Host). If they don't match, the Action will be rejected.

Server Actions doesn't use CSRF tokens, therefore HTML sanitization is crucial.

整理すると Next.jsのServer Actionsは三段構えでCSRF対策をしている ようです。

対策 仕組み
SameSite Cookie クロスサイトのPOSTにはCookieが付かない
POSTのみ許可 GET等のメソッドではServer Actionが起動しない
Origin / Host比較 OriginとHostが一致しない場合はリクエスト拒否

つまりNext.jsのCSRF対策は従来のToken方式ではなく、上述の令和時代のCSRFに沿ったものになっているようです。

Next.jsのソースコードを読む

公式ブログが「対策してますよ」と言ってるとはいえ「本当に?」という気持ちが少なからず残っています。

なので、実際にソースコードを確認してPOSTのみ許可とOrigin / Hostの比較をどう実装しているのかを確認します*7

全体フロー図

処理の流れが複雑*8なので、まずはCSRFの検証をするまでの動きを俯瞰しておきます*9

flowchart TD
    START["Server Actions 発火<br/>app-render.tsx:2195-2197<br/>handleAction() 呼び出し"]

    START --> A["action-handler.ts:556-562<br/>getServerActionRequestMetadata(req)"]

    A --> B{"isPossibleServerAction"}
    B -->|false| B_NG["return null<br/>(行585-586)"]
    B -->|true| C["action-handler.ts:618-622<br/>Origin ヘッダー取得<br/>originDomain = new URL(origin).host"]

    C --> D["action-handler.ts:623<br/>parseHostHeader(req.headers)<br/>Host / x-forwarded-host 取得"]

    D --> E{"Origin vs Host 比較"}

    E -->|"Originなし"| E_WARN["⚠️ warning のみ<br/>古いブラウザの場合に配慮し、処理は継続"]
    E -->|"一致"| OK["✅ CSRF検証パス<br/>Server Actions 実行"]
    E -->|"不一致"| F["csrf-protection.ts:72<br/>isCsrfOriginAllowed()"]

    F --> G{"allowedOrigins<br/>に含まれる?"}
    G -->|"はい"| OK
    G -->|"いいえ"| NG["❌ 500エラー<br/>'Invalid Server Actions request.'<br/>action-handler.ts:663"]

    style B_NG fill:#f9f,stroke:#333
    style NG fill:#f66,stroke:#333,color:#fff
    style OK fill:#6f6,stroke:#333
    style E_WARN fill:#ffe,stroke:#333

Server Actionについて

補足として、Server Actionについてさっくりと書いておきます。Server Actionは、クライアントから直接呼び出せるサーバーサイド関数です。

  // app/actions.ts                                                                                                                                 
  'use server'  // ← この宣言でServer Actionになる                                                                                                  
                                                                                                                                                    
  export async function createUser(formData: FormData) {
    const name = formData.get('name')
    await db.insert({ name })  // サーバー上でDBに直接アクセスできる
  }

  // app/page.tsx
  import { createUser } from './actions'

  export default function Page() {
    return (
      <form action={createUser}>  {/* ← Server Actionをformに渡す */}
        <input name="name" />
        <button type="submit">送信</button>
      </form>
    )
  }

'use server' で宣言された関数には、ビルド時に一意のactionIdが割り振られます。クライアント側ではその関数呼び出しが POSTリクエストに自動変換されます*10

もっと詳しく知りたい!という方は、カミナシさんのこちらの記事が詳しいので、ぜひ読んでみてください。

kaminashi-developer.hatenablog.jp

POSTのみ許可する仕組み

まず前の節でも触れた通り、Server ActionsはPOSTリクエストでのみ起動するようになっています。

そのうえで、サーバー側でリクエストを受けるとapp-render.tsxhandleAction()が呼び出され、さらにhandleAction()からaction-handler.tsgetServerActionRequestMetadata(req)が呼ばれます。

ここでリクエストがPOSTであることも含め、Server Actionsとして処理可能かどうかのチェックが行われます。

  // We don't actually support URL encoded actions, and the action handler will bail out if it sees one.
  // But we still want it to flow through to the action handler, to prevent changes in behavior when a regular
  // page component tries to handle a POST.
  const isURLEncodedAction = Boolean(
    req.method === 'POST' && contentType === 'application/x-www-form-urlencoded'
  )
  const isMultipartAction = Boolean(
    req.method === 'POST' && contentType?.startsWith('multipart/form-data')
  )
  const isFetchAction = Boolean(
    actionId !== undefined &&
      typeof actionId === 'string' &&
      req.method === 'POST'
  )

  const isPossibleServerAction = Boolean(
    isFetchAction || isURLEncodedAction || isMultipartAction
  )

このようにServer Actionsではいくつかの防御層によりPOSTリクエストのみを通すようになっています。

Origin / Host比較する仕組み

上述のisPossibleServerActionがtrueの場合、さらにaction-handler.tshandleAction()内でOriginとHostの比較が行われます。

処理としては、まずOriginヘッダの値を取得します。Origin: nullの場合はundefinedとして扱います。HostヘッダについてはparseHostHeader()で取得しています。parseHostHeader()x-forwarded-hostヘッダ > hostヘッダの順で見ていきます。

その上でOriginとHostを比較しています。その結果、

  • Originヘッダの値が取れなかった場合、警告だけ出して処理続行
  • 一致した場合は処理続行
  • 一致しなかったがisCsrfOriginAllowedに含まれる場合はOK
  • 一致せず、かつisCsrfOriginAllowedにも含まれない場合は攻撃とみなして処理中止

というかたちでCSRF攻撃を防止しています。

  const originHeader = req.headers['origin']
  const originDomain =
    typeof originHeader === 'string' && originHeader !== 'null'
      ? new URL(originHeader).host
      : undefined
  const host = parseHostHeader(req.headers)

  let warning: string | undefined = undefined

  function warnBadServerActionRequest() {
    if (warning) {
      warn(warning)
    }
  }
  // This is to prevent CSRF attacks. If `x-forwarded-host` is set, we need to
  // ensure that the request is coming from the same host.
  if (!originDomain) {
    // This might be an old browser that doesn't send `host` header. We ignore
    // this case.
    warning = 'Missing `origin` header from a forwarded Server Actions request.'
  } else if (!host || originDomain !== host.value) {
    // If the customer sets a list of allowed origins, we'll allow the request.
    // These are considered safe but might be different from forwarded host set
    // by the infra (i.e. reverse proxies).
    if (isCsrfOriginAllowed(originDomain, serverActions?.allowedOrigins)) {
      // Ignore it
    } else {
      if (host) {
        // This seems to be an CSRF attack. We should not proceed the action.
        console.error(
          `\`${
            host.type
          }\` header with value \`${limitUntrustedHeaderValueForLogs(
            host.value
          )}\` does not match \`origin\` header with value \`${limitUntrustedHeaderValueForLogs(
            originDomain
          )}\` from a forwarded Server Actions request. Aborting the action.`
        )
      } else {
        // This is an attack. We should not proceed the action.
        console.error(
          `\`x-forwarded-host\` or \`host\` headers are not provided. One of these is needed to compare the \`origin\` header from a forwarded Server Actions request. Aborting the action.`
        )
      }
    }
  }
export function parseHostHeader(
  headers: IncomingHttpHeaders,
  originDomain?: string
) {
  const forwardedHostHeader = headers['x-forwarded-host']
  const forwardedHostHeaderValue =
    forwardedHostHeader && Array.isArray(forwardedHostHeader)
      ? forwardedHostHeader[0]
      : forwardedHostHeader?.split(',')?.[0]?.trim()
  const hostHeader = headers['host']

  if (originDomain) {
    return forwardedHostHeaderValue === originDomain
      ? {
          type: HostType.XForwardedHost,
          value: forwardedHostHeaderValue,
        }
      : hostHeader === originDomain
        ? {
            type: HostType.Host,
            value: hostHeader,
          }
        : undefined
  }

  return forwardedHostHeaderValue
    ? {
        type: HostType.XForwardedHost,
        value: forwardedHostHeaderValue,
      }
    : hostHeader
      ? {
          type: HostType.Host,
          value: hostHeader,
        }
      : undefined
}

isCsrfOriginAllowed

isCsrfOriginAllowedについても見ておきましょう。packages/next/src/server/app-render/csrf-protection.tsに実装があります。

export const isCsrfOriginAllowed = (
  originDomain: string,
  allowedOrigins: string[] = []
): boolean => {
  // DNS names are case-insensitive per RFC 1035
  // Use ASCII-only toLowerCase to avoid unicode issues
  const normalizedOrigin = originDomain.replace(/[A-Z]/g, (c) =>
    c.toLowerCase()
  )

  return allowedOrigins.some((allowedOrigin) => {
    if (!allowedOrigin) return false

    const normalizedAllowed = allowedOrigin.replace(/[A-Z]/g, (c) =>
      c.toLowerCase()
    )

    return (
      normalizedAllowed === normalizedOrigin ||
      matchWildcardDomain(originDomain, allowedOrigin)
    )
  })
}

この関数は、リクエストのOriginヘッダのドメインが、サーバー側で許可されたドメインのリストallowedOriginsに含まれているかどうかをチェックします。Next.jsの公式ドキュメントに記載があります。

nextjs.org

A list of extra safe origin domains from which Server Actions can be invoked. Next.js compares the origin of a Server Action request with the host domain, ensuring they match to prevent CSRF attacks. If not provided, only the same origin is allowed.

結論

POSTのみ許可する処理とOrigin/Hostの比較処理がちゃんと実装されていました。これで万事OK!!

...とはいきません。Custom Route Handlers(route.ts)のことを考える必要があります。

実はここまで説明したCSRF保護はすべてServer Actions に対してのみ機能します。Custom Route HandlersはServer Actionsと処理経路が変わるため、別途CSRF対策が必要です。公式ブログも次のように注意を促しています。

When Custom Route Handlers (route.ts) are used instead, extra auditing can be necessary since CSRF protection has to be done manually there.

このためroute.ts においては、令和時代のCSRF対策や従来のCSRF Token方式によるCSRF対策が必要となります。

おわりに

きっかけは何気ないXのポストに結構な反響があったことでした。

おかげで深掘りするきっかけになったし、実際たくさんのことを学べる非常によい機会になりました。

*1:このセクションはToken方式の解像度を上げるためのもので、記事の本題であるNext.jsのCSRF対策の方法にはあまり関係ありません。なので、読み飛ばしていただいても問題ありません。

*2:Laravelのテンプレートエンジン

*3:念のため書いておくと、CSRF Token方式がダメというわけではありません。後述しますが非常に古いブラウザをサポートする必要がある場合など、様々なパターンにおいて機能してくれる手堅い方式であると考えています。

*4:ただしJackさんは先の記事内で、この結果は副次的なものと結論づけています。 https://blog.jxck.io/entries/2024-04-26/csrf.html#:~:text=%E4%BB%8A%20SameSite%20Lax%20%E3%81%8C%20default%20%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%9F%E3%81%AE%E3%82%92%E7%90%86%E7%94%B1%E3%81%AB%E3%80%81%E3%80%8COrigin%20%E3%81%AE%E3%83%81%E3%82%A7%E3%83%83%E3%82%AF%E3%80%8D%E3%82%92%E6%80%A0%E3%81%A3%E3%81%9F%E5%AE%9F%E8%A3%85%E3%82%92%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E3%81%AE%E3%81%A7%E3%81%82%E3%82%8C%E3%81%B0%E3%80%81%E3%81%9D%E3%82%8C%E3%81%AF%E6%9C%AC%E8%B3%AA%E7%9A%84%E3%81%AA%E5%AF%BE%E7%AD%96%E3%82%92%E6%80%A0%E3%81%A3%E3%81%9F%E7%89%87%E6%89%8B%E8%90%BD%E3%81%A1%E3%81%AE%E5%AE%9F%E8%A3%85%E3%81%A7%E3%80%81%E3%81%9F%E3%81%BE%E3%81%9F%E3%81%BE%E5%8A%A9%E3%81%8B%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E3%81%A0%E3%81%91%E3%81%A0%E3%81%A8%E8%A8%80%E3%81%88%E3%82%8B%E3%81%A0%E3%82%8D%E3%81%86%E3%80%82

*5:副作用のある API を GET にしないの意

*6:ネイティブformやimgタグ等のno-corsリクエストでは付与されない

*7:SameSite Cookieはブラウザ頼みの仕組みなので確認できません。

*8:実際はもっとステップが多いです。この図では説明の簡略化のために本来であればapp-call-server.ts:callServer()から発火する流れを省略しています。Next.jsが想定するPOSTによるServerActionが発火したところからの流れを見ています。

*9:余談ですが、AIがすぐにMermaidの図を作ってくれるおかげで本当にコードが読みやすくなりました。昔はソースコードリーディング記事を書く時には図式化するのがめちゃ面倒で、ひたすらファイル名とソースコードを並べてました。

*10:next/src/client/components/router-reducer/reducers/server-action-reducer.ts:136

TypeScript製ライブラリ VerifyFetch で「切れない」ファイルダウンロードを作ってみる

Webブラウザで数KBのJSONを取得するなら標準の fetch で十分ですが、例えばブラウザ上のWeb Viewerで画像を数十枚表示するなどのユースケースに標準のfetchで対応しようとする場合以下のような問題がつきまとうことが多いでしょう。

  1. 再開不能: ネットワーク瞬断やリロードでダウンロード進捗が0に戻る
  2. 整合性不明: ダウンロードが完了しない限りファイルが破損なくダウンロードできているかわからない
  3. メモリ逼迫: 全データをメモリに展開する必要があり、クラッシュのリスクがある

これらの問題を解決するためのライブラリとして、今回は VerifyFetch を使ってみようと思います。

github.com

VerifyFetchの特徴

先述の3つの問題への対処として、VerifyFetchは3つの機能を提供しています。

1. 分割ダウンロードと都度検証

VerifyFetchでは、あらかじめサーバーサイドでマニフェストと呼ばれるファイルを作成します。そのためのAPIとして、generateChunkedHashesを提供しています。以下のコードは、large-file.datという50MBのファイルに対するマニフェストを作成するサンプルです。

import { generateChunkedHashes } from "verifyfetch";
import fs from "fs/promises";

async function main() {
    const filePath = "public/large-file.dat";
    const outputPath = "public/vf.manifest.json";

    console.log(`Reading ${filePath}...`);
    const data = await fs.readFile(filePath);

    console.log("Generating chunked hashes...");
    const chunkedInfo = await generateChunkedHashes(data);

    const manifest = {
        url: "/large-file.dat",
        chunked: chunkedInfo,
    };

    console.log(`Writing manifest to ${outputPath}...`);
    await fs.writeFile(outputPath, JSON.stringify(manifest, null, 2));
    console.log("Done.");
}

main().catch(console.error);

この結果、以下のようなファイルが作成されます。

{
  "url": "/large-file.dat",
  "chunked": {
    "root": "sha256-hZdsFAJ7IxJmmGc6vd87yRxp0uO+Qwdj/VyCew6VvO8=",
    "chunkSize": 1048576,
    "hashes": [
      "sha256-bQr9R3b7zx8lUt2+AXadihrLpyidXPQlW2DCsTSElp8=",
      "sha256-Tpan8NLdrJqzFw3ty7j3pK/JN0QL5dW0ICwWD64Jz9s=",
      "sha256-KPcMNIOVmkjqZYDRB3+v+uxpoHbOPRUq3IZ23QpiajQ=",
      "sha256-HPtu7eAPGuNOlAALuEBHYgwOQ27qsyn9cD1GEYlCV6Q=",
      "sha256-Eic95JD6pGeB+F4YSI14dsCg1Y5B9GS6GjNVoViuvGI=",
      "sha256-TXgmw5fJzNmt1qOkAOQTmql0R0MQmCjPKehadxytZF8=",
      "sha256-69fBRWmoOLFRi2LWZzfwuyoWmPTvgyINycAWIu1n7js=",
      "sha256-Ug6s9cQTsh8uF//Dz+il4VE9hBTYKQRK4QtqulXu0eE=",
      "sha256-aRdJhtZpphtnVGZC507w2bSyC+ZoVt0zOP1YSkbR/pY=",
      "sha256-V7uoRJ8ea8GkYY+Y0pxLn9LFd31oUb1e3kOjhTKz+Zo=",
      "sha256-IVXu0E3xCtrJbeD/+uhZKHClLTkLs2dcLnB+lugPfJo=",
      "sha256-mDtGSQK8A4cTI1X6yXAYpgR9q3jAk/+lhKo7Lt6yv7g=",
      "sha256-L2z4mQ/6yxzZs7DolSKGJ6ova4iszFOllIkzSmiF3Is=",
      "sha256-8rzihS9fY5PHB4wk51k23bUcOiQ04bmoti1eJO3NvKI=",
      "sha256-zwatRRvu5azwL1tx6w5jJBOjF+EAVBTuWza35ak0dAM=",
      "sha256-aWYLDphFKIufpguThJzlgsC8tha4u3AchlJFg7LvhwI=",
      "sha256-HieeGiMVmcO5MRO+bOhkk5oQFzrGSKrGqdkkDUHEC6g=",
      "sha256-sGwnZ0ZCd82VAVxalrFwI87JUs9i599fM4DohealFb0=",
      "sha256-Z9oQLaHST255fxw7xx6ctF/nisNwAkLHQKD3t72ESqw=",
      "sha256-cXJQbduTZhhtIFulwncBtceJJ5achTsciXdm40NYa1M=",
      "sha256-dbJOCndq4DUyh5DPRpc5jCDlaMus6s2aVYDIG1YP6oc=",
      "sha256-f2/KNeKKGXwK416groWO4lboQkgDVNuGugrqxPAbcd0=",
      "sha256-g0DR0qoxbcMy7HtK05k6ib0OUvqvg2l/h32KSIB3pOI=",
      "sha256-JuL4OnqB2mdFdMDDgWYXVcmfDG32u12CFRUv+qBa/WQ=",
      "sha256-WkFol6BlcVeLgbFWyYLTpupmWiw0bUHg3zpws1AyWmk=",
      "sha256-loM7jeDmZjO+LxJV3uYs/17R1k7kC62Jkg/m3fucA5s=",
      "sha256-ibMZokxDUhbCa2FqGqvpzV6nGUThYtmBKDZbBQMvGtY=",
      "sha256-T3dkYIjtu1cgRDTXbPclaJNis+t71O56D8nDWPwc8No=",
      "sha256-k9s0+OU+jEWrxwy+FEGaW/SM2jdkAqQzKH36FyH0ZC0=",
      "sha256-F12qrXAR40as/GHTUIZIR2IZ3xRh1T8J1DCW0OZBh4g=",
      "sha256-hhcFqAP8fWuv1jiLpW4pBXBXKFgo70iXv2IS5d0+Kuk=",
      "sha256-5NmGGiL44bWEGGHJ3iymUDQzOFpKhcRFZ6immrs7jKw=",
      "sha256-l3v/mZBx/sg7scubIguWk9+O1P4Idd6pWWKu5N64uac=",
      "sha256-eG2NyV4APsYfjRNfKudynagKQkLdUClpOV2SIVmA3Bk=",
      "sha256-F8a7crRr7nhBQtw+rzzxVNOcrGiDtOle5rl1y/uYHeA=",
      "sha256-oNxtqT8JvxY9NuNHtzED5PetNmUKNUn0MRzqAwI/FwE=",
      "sha256-Wd1eQnRxoK+dcqrt/vHJ0ez6Id1fd1ri8jXiUC4CiQ0=",
      "sha256-6v1voVyGr07iebH0J/dvuTU8NyekLQ3RQ+Qnr0pRxKA=",
      "sha256-Muab0gbDyQbktRy+FnuZQ2aH5ri1l/h1Gp+8JEwnF2U=",
      "sha256-vdmztQ3wlUGMqKhjc+A9fbI5CRYPPnhT2K072nfGt54=",
      "sha256-w7dZjLVlBsQ50k4PtqWEes8gTAvpwR8u2ihT2LVQwMg=",
      "sha256-DduQQ//Nt/DZC894Y9Xbr9+EwF8r4HcV6G3ntwdBBbc=",
      "sha256-S6RerNGaoNT+8MjpLXDW3hPB9rlnrgEkMB3zyFUyNDY=",
      "sha256-CUFe2BqMcbJpD8lU1xqoRaNYzLs1oquZsFFv3Zh53Ig=",
      "sha256-GhNa3dWAP2VzzKeglNxJWSiDJds+Duk53mAXEl7dZI4=",
      "sha256-LpvMAA5/OybmYeBmolUU3PSplFalJNa74nuGXFQWmnA=",
      "sha256-ZaQs6H+PnATkWFHPiRxCYlERspFzhJHRHs9RrAH7Lzo=",
      "sha256-hekQs1LRpF/ilGfN+UKdopn+9WTgTCIrhZkalPSNYHA=",
      "sha256-Jm0dAfIj1yC4eFgz9sujx41DVkUPUgmxbOIyxMAgnDo=",
      "sha256-UQHBasOxFqLfV+FRaTbdbhNDAu80GXuiHywsFsxBzw8="
    ]
  }
}

これはファイル全体のハッシュと、1MBごとにファイルを分割した際のハッシュです。

クライアント側では、本体のダウンロードの前にまずこのマニフェストファイルをfetchします。そして本題となる後続のダウンロードの際にはマニフェストであらかじめ決められたチャンクごとに、マニフェストハッシュ値とダウンロードしたデータのハッシュ値を計算し、整合性を確認します。不整合があれば再フェッチを行い、整合していればダウンロードを継続します。

最終的にファイルをすべて読み込んだのち、rootのハッシュ値とダウンロードしたデータから算出したハッシュが合致するかを検証し、データの整合性を担保します。

2. IndexedDBへの永続化

VerifyFetchでは途中までダウンロードしたデータをIndexedDBへ保存します。これにより、メモリ上に展開されるデータは常にチャンクサイズ分だけのデータとなります。

またこれにより、ダウンロード中にリロードが発生した場合でも、途中からデータの取得を再開することができます。

ダウンロードの中断と再開をfetchと比較してみる

ではここからは、fetchと比較する形でデータのダウンロードの中断と再開の挙動を検証してみようと思います。今回の検証環境はこちらにあります。

github.com

ダウンロードを遅くするためのサーバーを用意する

今回はHonoを使ってGET /large-file.datのエンドポイントを提供するサーバーをサクッと作りました。実装の肝は、rangethrottleです。

app.get("/large-file.dat", async (c) => {
    const filePath = "./public/large-file.dat";
    const stats = statSync(filePath);
    const fileSize = stats.size;
    const range = c.req.header("range");

    let start = 0;
    let end = fileSize - 1;
    let status = 200;
    let headers = {
        "Content-Type": "application/octet-stream",
        "Accept-Ranges": "bytes",
    };

    if (range) {
        const parts = range.replace(/bytes=/, "").split("-");
        start = parseInt(parts[0], 10);
        end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;

        if (start >= fileSize) {
            c.status(416);
            return c.text("Requested Range Not Satisfiable");
        }

        const chunksize = end - start + 1;
        headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
        headers["Content-Length"] = chunksize.toString();
        status = 206;
    } else {
        headers["Content-Length"] = fileSize.toString();
    }

    const fileStream = createReadStream(filePath, { start, end });

    const BPS = 1024 * 1024;
    const throttledStream = fileStream.pipe(new Throttle(BPS));

    const readable = Readable.toWeb(throttledStream);

    return c.newResponse(readable, status, headers);
});

Range Requests (RFC 7233)

tex2e.github.io

VerifyFetch が「続きからダウンロード」を行うためには、サーバーが Range Requests (RFC 7233) に対応している必要があります。クライアントが Range: bytes=5000000-(5MB目からくれ)と要求してきた際、サーバーはファイルの該当箇所を切り出して 206 Partial Content を返す実装を行っています。

Throttle

通常のローカルサーバーの実装ではダウンロード完了までが早すぎて「途中中断」の検証ができません。そこで throttle パッケージを使い、擬似的に転送速度を絞ります。

github.com

throttle パッケージは、Transform Streamという機能を使いデータの流れを 指定した速度(バイト/秒) に制限します。今回は1MB/sに速度を絞ることで、large-file.datのダウンロードに最低50秒かかるように制限しています。

// 1. ファイルを読み込むストリームを作成
const fileStream = createReadStream(filePath, { start, end });

// 2. 帯域制限を設定:1MB/s
// これにより 50MB のファイルは「必ず50秒」かかるようになる
const BPS = 1024 * 1024;
const throttledStream = fileStream.pipe(new Throttle(BPS));

fetchとVerifyFetchを実行するクライアントを用意する

それぞれダウンロード開始・中断・再開ができるようにしています。中断や再開にはsignalを使います。それぞれの実装は以下の通りです。

import {
    verifyFetchResumable,
    clearOldDownloads,
    getDownloadProgress,
} from "/verifyfetch/index.js";

const FILE_URL = "/large-file.dat";
const MANIFEST_URL = "/vf.manifest.json";

// --- Standard Fetch State ---
let fetchController = null;
let fetchBytesReceived = 0;
let fetchTotalBytes = 0;
let fetchChunks = [];
let fetchIsPaused = false;

// --- VerifyFetch State ---
let verifyController = null;
let verifyIsPaused = false;
let verifyManifest = null;

// UI Elements
const fetchStatus = document.getElementById("fetch-status");
const fetchProgress = document.getElementById("fetch-progress");
const fetchBtnStart = document.getElementById("fetch-btn-start");
const fetchBtnPause = document.getElementById("fetch-btn-pause");
const fetchBtnResume = document.getElementById("fetch-btn-resume");

const verifyStatus = document.getElementById("verify-status");
const verifyProgress = document.getElementById("verify-progress");
const verifyBtnStart = document.getElementById("verify-btn-start");
const verifyBtnPause = document.getElementById("verify-btn-pause");
const verifyBtnResume = document.getElementById("verify-btn-resume");

// --- Helper: Update UI ---
function updateFetchUI(status, progress) {
    if (status) fetchStatus.textContent = status;
    if (progress !== undefined) fetchProgress.value = progress;

    fetchBtnStart.disabled = fetchController || fetchIsPaused;
    fetchBtnPause.disabled = !fetchController;
    fetchBtnResume.disabled = !fetchIsPaused;
}

function updateVerifyUI(status, progress) {
    if (status) verifyStatus.textContent = status;
    if (progress !== undefined) verifyProgress.value = progress;

    verifyBtnStart.disabled = verifyController || verifyIsPaused; // Disable start if running or paused (use resume)
    verifyBtnPause.disabled = !verifyController;
    verifyBtnResume.disabled = !verifyIsPaused;
}

// --- Standard Fetch Implementation ---

async function startFetch() {
    fetchChunks = [];
    fetchBytesReceived = 0;
    fetchIsPaused = false;
    updateFetchUI("Starting...", 0);

    try {
        fetchController = new AbortController();
        const response = await fetch(FILE_URL, {
            signal: fetchController.signal,
        });

        if (!response.ok) throw new Error(`HTTP ${response.status}`);

        const contentLength = response.headers.get("Content-Length");
        fetchTotalBytes = contentLength ? parseInt(contentLength, 10) : 0;

        updateFetchUI("Downloading...", 0);
        await readStream(response.body);

        updateFetchUI("Completed!", 100);
        fetchController = null;
    } catch (err) {
        if (err.name === "AbortError") {
            updateFetchUI("Paused");
        } else {
            updateFetchUI(`Error: ${err.message}`);
            fetchController = null;
        }
    }
}

async function pauseFetch() {
    if (fetchController) {
        fetchController.abort();
        fetchController = null;
        fetchIsPaused = true;
        updateFetchUI("Paused");
    }
}

async function resumeFetch() {
    if (!fetchIsPaused) return;
    fetchIsPaused = false;
    updateFetchUI("Resuming...");

    try {
        fetchController = new AbortController();
        const headers = { Range: `bytes=${fetchBytesReceived}-` };
        const response = await fetch(FILE_URL, {
            headers,
            signal: fetchController.signal,
        });

        if (!response.ok) throw new Error(`HTTP ${response.status}`);

        if (response.status !== 206) {
            console.warn(
                "Server did not return 206 Partial Content. Restarting?",
            );
        }

        updateFetchUI("Downloading...");
        await readStream(response.body);

        updateFetchUI("Completed!", 100);
        fetchController = null;
    } catch (err) {
        if (err.name === "AbortError") {
            updateFetchUI("Paused");
        } else {
            updateFetchUI(`Error: ${err.message}`);
            fetchController = null;
        }
    }
}

async function readStream(readableStream) {
    const reader = readableStream.getReader();

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        fetchChunks.push(value);
        fetchBytesReceived += value.length;

        if (fetchTotalBytes > 0) {
            const percent = (fetchBytesReceived / fetchTotalBytes) * 100;
            updateFetchUI(null, percent);
        }
    }
}

// --- VerifyFetch Implementation ---

async function loadManifest() {
    const res = await fetch(MANIFEST_URL);
    verifyManifest = await res.json();
}

async function runVerifyFetch() {
    if (!verifyManifest) await loadManifest();

    verifyController = new AbortController();
    const signal = verifyController.signal;

    try {
        const fetchImpl = (url, init) => {
            return fetch(url, { ...init, signal });
        };

        await verifyFetchResumable(FILE_URL, {
            chunked: verifyManifest.chunked,
            fetchImpl: fetchImpl,
            onProgress: (progress) => {
                const percent =
                    (progress.bytesVerified / progress.totalBytes) * 100;
                const speed = progress.speed
                    ? (progress.speed / 1024 / 1024).toFixed(2) + " MB/s"
                    : "";
                updateVerifyUI(
                    `Downloading... (${Math.round(percent)}%) - ${speed}`,
                    percent,
                );
            },
            onResume: (state) => {
                console.log("Resumed from state:", state);
                updateVerifyUI("Resumed download");
            },
        });

        updateVerifyUI("Verified & Completed!", 100);
        verifyController = null;
        verifyIsPaused = false;
    } catch (err) {
        if (err.name === "AbortError" || signal.aborted) {
            updateVerifyUI("Paused");
            verifyIsPaused = true;
        } else {
            console.error(err);
            updateVerifyUI(`Error: ${err.message}`);
            verifyController = null;
        }
    }
}

async function startVerify() {
    await clearOldDownloads(0);
    verifyIsPaused = false;
    updateVerifyUI("Starting...", 0);
    await runVerifyFetch();
}

async function pauseVerify() {
    if (verifyController) {
        verifyController.abort();
        verifyController = null;

        verifyIsPaused = true;
        updateVerifyUI("Paused");
    }
}

async function resumeVerify() {
    verifyIsPaused = false;
    updateVerifyUI("Resuming...");
    await runVerifyFetch();
}

async function checkResumeState() {
    try {
        const progress = await getDownloadProgress(FILE_URL);
        if (
            progress &&
            progress.bytesVerified < (progress.totalBytes || Infinity)
        ) {
            verifyIsPaused = true;
            const percent =
                (progress.bytesVerified / progress.totalBytes) * 100;
            updateVerifyUI(`Paused (Resumable)`, percent);
        }
    } catch (e) {
        console.warn("Failed to check resume state:", e);
    }
}

// Event Listeners
fetchBtnStart.addEventListener("click", startFetch);
fetchBtnPause.addEventListener("click", pauseFetch);
fetchBtnResume.addEventListener("click", resumeFetch);

verifyBtnStart.addEventListener("click", startVerify);
verifyBtnPause.addEventListener("click", pauseVerify);
verifyBtnResume.addEventListener("click", resumeVerify);

// Initial UI
updateFetchUI("Ready", 0);
updateVerifyUI("Ready", 0);

// Check if there is a pending download
checkResumeState();

検証

youtu.be

まず両者共に、中断と再開は問題なくできています。

ここで目に見える両者の違いとして、VerifyFetchではダウンロードの進捗と速度が出ていることです。

VerifyFetchではprogress.speedというAPIが提供されているおかげで、ダウンロードの速度を計算することができます。また、fetchはデータ破損を検知できないため進捗は絶対的なものではありません。そのため進捗率(%)の表示を避けています。一方でVerifyFetchはマニフェストを使った 都度検証(Hashing) により、進捗バーは「受信した量」ではなく「保証された量」を正確に反映しています。

そして、中断中にリロードをすると...

fetchは進捗が失われ、再び0からのダウンロードが必要になりました。これは、途中までのダウンロードデータがメモリ上に存在するためです。一方VerifyFetchは進捗が失われることなく、ダウンロードを再開できました。これはIndexedDBに途中までのダウンロードデータが永続化されているからです。

おわりに

標準の fetch でこれらを実装しようとすると、IndexedDB の制御やハッシュ計算、Range リクエストの管理など、膨大なコードが必要になります。VerifyFetchは、それらをカプセル化し、堅牢なファイル転送を容易にする強力なツールと言えるのではないでしょうか。

Google CloudのAPIキー管理に関する最新のセキュリティベストプラクティスを適用する

Googleから『Review Google Cloud credential security best practices』というメールが届いていた。

一通り書かれている内容を要約すると、

サービスアカウントキーやAPIキーの最新のベストプラクティスを送るから、ちゃんとセキュリティ対策しとけよ

ってことらしい*1。詳しく見ていくと、自分が知らない内容もあった。なので、知ってたことと知らなかったことに分けつつ、知らなかったことについてはやったことを具体的にメモとして残しておく。

知ってたこと

Zoro Code Storage

単語自体は知らなかったけど、「コードやらバージョン管理にキーを含めんじゃねぇ」ってこと。この場合、Secret Managerを使うべき。

cloud.google.com

API制限の適用

制限なしのAPIキーではなく、特定のAPIに制限し、環境制限(IPアドレス、HTTPリファラーなど)を適用しようねってこと。

最小権限の原則

いわずもがな。ちなみにIAM Recommenderを使うと、サービスアカウントの未使用の権限を削除し、必要最小限のアクセスのみを許可できる*2

docs.cloud.google.com

知らなかったこと

アクティブキーの監査

サービスアカウントとキーの使用状況をモニタリングすることができるらしい。

docs.cloud.google.com

Cloud Monitoringで、指標 > IAM Service Account > Service account > Service account key authentication eventsを選択すると、アクティブなキーを見ることができる。

ので、逆にここで一定期間アクティブでないキーに関しては、無効にしてしまおうということらしい。

サービスアカウントキーの必須ローテーション

docs.cloud.google.com

組織のポリシー > iam.serviceAccountKeyExpiryHoursを有効にすると、すべてのサービスアカウントキーに有効期間を制限することができる。

またiam.managed.disableServiceAccountKeyCreationを有効にした場合は、新しいサービスアカウントキーの作成を無効にできる。

おわりに

サービスアカウントキーまわりでいうと、最近Artifact Registoryの脆弱性スキャンでもサービスアカウントキーやAPIキーのシークレットが特定できるようになった*3

docs.cloud.google.com

この辺りからも、適切なセキュリティのベストプラクティスを伴わない長期間有効な認証情報が不正アクセスに対する最大のセキュリティリスクであり続けていそうなのが伺える。

今後も引き続き、気をつけていきたい。

*1:「そもそも、サービスアカウントキーではなくWorkload Identity使おうよ」という声が聞こえるが、今回は聞かなかったことにする。

*2:小さく出過ぎて、結局大きくしていくハメになるところまでワンセット

*3:記事執筆時点の2026/2/11時点ではまだプレビュー版