Deno Fresh のミドルウェアを書いてみる

## はじめに
Freshを使いたいが何もわからない。
手始めにミドルウェアを書いて勉強してみましょう

## 検証環境
- Deno: v2.2.4
- Fresh: v1.7.3

## 書いてみる内容

DenoやFreshにはCSRF対策の機構が入ってないらしいので、それを書いてみましょう。
対策手法としては [今時の CSRF 対策ってなにをすればいいの？](https://tech.basicinc.jp/articles/231) の記事で紹介されていた Double Submit Cookie で実装してみます。

## 先にネタバレ

ミドルウェアのハンドラの中でRequestオブジェクトのメソッド `formData()`を呼ぶことでフォームデータを取得できるのですが、Requestオブジェクトに対して`formData()`を呼べるのは1回だけという制約があります。

特定のミドルウェアの中でこれを呼ぶと、それ以降にロードさせた別のミドルウェアや`/routes/`下のTSXで再度呼ぶことができません。

というわけで、先にFreshContextのstateにフォームデータをセットするミドルウェアを書きます。

```typescript:/middlewares/form_data.ts
import { FreshContext } from "$fresh/server.ts"

export async function formDataHandler(req: Request, ctx: FreshContext) {
    if (req.method == "POST") {
        ctx.state.formData = await req.formData();
    }
    return await ctx.next();
}
```

:::note warn
このミドルウェアは、フォームデータを利用するその他のミドルウェアより先にロードしてください。
:::

:::note info
この後にロードされたミドルウェア等で`ctx.state.formData`を参照することでフォームデータを得ることができます。
:::

## 本題

フォームデータにアクセスする準備ができたところで、CSRF対策の部分を書いていきます。
処理の流れは、下記の通りです。

### リクエストに対する処理
CookieからCSRFトークンを取得する
　↓
トークンが取得できた場合、コンテキストのstateにセットする
取得できなかった場合は、新しいトークンを生成しコンテキストのstateにセットする
　↓
リクエストメソッドがPOSTの場合は、トークンを検証する。
検証に失敗した場合は、HTTPステータスコード400のレスポンスを返す。

### レスポンスに対する処理
新しいトークンを生成していた場合、Cookieにセットする。

```typescript:/middlewares/csrf_protect.ts
import { FreshContext } from "$fresh/server.ts"
import { setCookie, getCookies } from "https://deno.land/std@0.74.0/http/mod.ts";

export async function csrfProtectHandler(req: Request, ctx: FreshContext) {
    const cookies = getCookies(req);
    const csrf_token: String = cookies.csrf_token;

    let new_csrf_token = "";
    if (csrf_token) {
        ctx.state.csrfToken = csrf_token;
    } else {
        new_csrf_token = await generateToken();
        ctx.state.csrfToken = new_csrf_token;
    }

    if (req.method == "POST") {
        if (
            !csrf_token ||
            (csrf_token != ctx.state.formData.get("csrf_token"))
        ) {
            return new Response(null ,{status: 400});
        }
    }

    const res = await ctx.next();
    if (new_csrf_token) {
        setCookie(
            res,
            {
                name: "csrf_token", 
                value: new_csrf_token,
                sameSite: "Strict",
                httpOnly: true,
                secure: true,
                path: "/",
            }
        );
    }
    return res;
}

async function generateToken() {
    const { generateKey } = await import('node:crypto');
    return new Promise((resolve, reject) => {
        const key = generateKey('hmac', { length: 512 }, (err, key) => {
            if (err) resolve('');
            const token = key.export().toString('hex');
            resolve(token);
        });
    });
}
```

生成したトークンはTSX上でform要素の中にhidden inputとして毎回埋め込んでもいいですが、毎回埋め込まなくてもいいように下記のような`_app.tsx`とJavaScriptを用意してみました。

```tsx:/routes/_app.tsx
import { PageProps } from "$fresh/server.ts";
export default function({ Component, state }: PageProps) {
    return (
        <html>
            <head>
                {/* 中略 */}
            </head>
            <body>
                <div>
                    <Component />
                </div>
                <div style="display:none;" id="csrf_token" data-value={state.csrfToken}></div>
            </body>
        </html>
    );
}
```

```javascript:/static/csrf_protect.js
document.addEventListener("DOMContentLoaded", function(){
    const div_csrf_token = document.querySelector("div#csrf_token");
    if (!div_csrf_token) {
        return;
    }
    const csrf_token = div_csrf_token.dataset.value;
    const forms = document.querySelectorAll("form");
    forms.forEach((form) => {
        const csrf_token_input = document.createElement("input");
        csrf_token_input.setAttribute("type", "hidden");
        csrf_token_input.setAttribute("name", "csrf_token");
        csrf_token_input.setAttribute("value", csrf_token);
        form.appendChild(csrf_token_input);
    });
});
```

## おわりに
Freshのミドルウェア完全に理解した。
(馬鹿の山)

## この記事内容のライセンス

```text:LICENSE
MIT License

Copyright (c) 2025 CIB-MC

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```