Blitz.js で気をつけたいこと(脆弱性対策)

7 min読了の目安(約6500字TECH技術記事

まずは下記の html を見てください [1]

<!DOCTYPE html>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ログインフォーム</title>
<form method="post">
  <input name="name">
  <input name="password" type="password">
  <button type="submit">login</button>
</form>
<script>
  document.querySelector('form')?.addEventListener('submit', e => {
    e.preventDefault()
    const name = document.querySelector('form [name="name"]')?.value
    const password = document.querySelector('form [name="password"]')?.value
    fetch('/api/query', { method: 'post', body: 'SELECT * FROM users' })
      .then(r => r.json())
      .then(rows => {
        if (rows.find(u => u.name === name && u.password === password)) {
          location.href = '/admin-page'
        } else {
          alert('失敗しました')
        }
      })
  })
</script>

やばいですね

さて上記のやばさが分かった上で Blitz.js で書いていく上で
このようなコードを書いてしまわないようにするために気をつけたい点を書いていきます

queries と mutations

詳細は公式の説明こちらの記事が参考になるのですが
api, queries, mutations という名前のディレクトリの中身にあるファイルはサーバサイド用のコードとして扱われるようです
データベースに保存するためのデータの組み立てやデータベースからの取り出しはここでやった方が良いですね

ロールの確認や post したユーザの id を使った create は注意が要ります

blitz generate all articles title:String content:String belongsTo:User を実行すると
下記のようなコードが生成されます [2] [3]

app/articles/mutations/createArticle.ts
import { Ctx } from "blitz"
import db, { ArticleCreateArgs } from "db"

type CreateArticleInput = Pick<ArticleCreateArgs, "data">
export default async function createArticle({ data }: CreateArticleInput, ctx: Ctx) {
  ctx.session.authorize()

  const article = await db.article.create({ data })

  return article
}
app/articles/pages/articles/new.tsx
import React from "react"
import Layout from "app/layouts/Layout"
import { Link, useRouter, useMutation, BlitzPage } from "blitz"
import createArticle from "app/articles/mutations/createArticle"
import ArticleForm from "app/articles/components/ArticleForm"

const NewArticlePage: BlitzPage = () => {
  const router = useRouter()
  const [createArticleMutation] = useMutation(createArticle)

  return (
    <div>
      <h1>Create New Article</h1>

      <ArticleForm
        initialValues={{}}
        onSubmit={async () => {
          try {
            const article = await createArticleMutation({ data: { name: "MyName" } })
            alert("Success!" + JSON.stringify(article))
            router.push("/articles/[articleId]", `/articles/${article.id}`)
          } catch (error) {
            alert("Error creating article " + JSON.stringify(error, null, 2))
          }
        }}
      />

      <p>
        <Link href="/articles">
          <a>Articles</a>
        </Link>
      </p>
    </div>
  )
}

NewArticlePage.getLayout = (page) => <Layout title={"Create New Article"}>{page}</Layout>

export default NewArticlePage

pages/articles/new.tsx でエラーが出ているのですが
例えば下記のように修正するとエラーは消えて問題なく動いて見えますが…

app/articles/pages/articles/new.tsx への変更
- import { Link, useRouter, useMutation, BlitzPage } from "blitz"
+ import { Link, useRouter, useMutation, useSession, BlitzPage } from "blitz"
  // 略
+ const session = useSession()
  // 略
- const article = await createArticleMutation({ data: { name: "MyName" } })
+ const article = await createArticleMutation({ data: {
+   title: "MyName",
+   content: "",
+   user: {
+     connect: { id: session.userId },
+   }
+ } })

この実装では悪意あるユーザが記事に紐づく著者を自由に設定できてしまいます [4]

対策

auth ディレクトリを参考に正しく実装していきます

まずは validations.ts を作成します
zod については公式を参照してください [5]

app/articles/validations.ts
import * as z from "zod"

export const CreateInput = z.object({
  title: z.string(),
  content: z.string(),
})
export type CreateInputType = z.infer<typeof CreateInput>

export const UpdateInput = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
})
export type UpdateInputType = z.infer<typeof UpdateInput>

次に createArticle.ts を編集していきます

入力をきちんと検証しないと自動生成したい内容を手動で入力されてしまうかもしれません
一応 parse した返り値から各プロパティを取り出してますが( zod を使っている限りでは )そのまま create などに渡しても問題ないと思います

app/articles/mutations/createArticle.ts への変更
  import { Ctx } from "blitz"
+ import db from "db"
- import db, { ArticleCreateArgs } from "db"
+ import { CreateInput, CreateInputType } from "../validations"

- type CreateArticleInput = Pick<ArticleCreateArgs, "data">
+ type CreateArticleInput = { data: CreateInputType }
  export default async function createArticle({ data }: CreateArticleInput, ctx: Ctx) {
    ctx.session.authorize()

+   const { title, content } = CreateInput.parse(data)
+ 
-   const article = await db.article.create({ data })
+   const article = await db.article.create({
+     data: {
+       title,
+       content,
+       user: {
+         connect: { id: ctx.session.userId },
+       },
+     },
+   })

    return article
  }

最後に new.tsx を修正します

app/articles/pages/articles/new.tsx への変更
- const article = await createArticleMutation({ data: { name: "MyName" } })
+ const article = await createArticleMutation({ data: { title: "MyName", content: "" } })

同様に create や delete も修正していきます
必要であれば getArticle なども修正が要ると思います( 非公開な記事などを実装する場合 )

app/articles/mutations/updateArticle.ts
import { Ctx, AuthorizationError } from "blitz"
import db from "db"
import { UpdateInput, UpdateInputType } from "../validations"

type UpdateArticleInput = UpdateInputType

export default async function updateArticle(input: UpdateArticleInput, ctx: Ctx) {
  ctx.session.authorize()

  const { id, title, content } = UpdateInput.parse(input)
  const where = { id, userId: ctx.session.userId }
  const data = { title, content }

  const { count } = await db.article.updateMany({ where, data })
  if (!count) throw new AuthorizationError()
  const article = await db.article.findOne({ where })

  return article
}

pages

前述とは逆に、ページの情報は公開されていると思って良さそうです

極端な例として例えば管理者専用ページとして下記のようなフォームを置いてもバレます

app/users/pages/[userId].tsx
<div>
  <label htmlFor={id}>このユーザがガチャを引いたときの SSR 排出率を下げる</label>
  <input type="checkbox" checked={checked} id={id} onChange={onChange} />
</div>

あとがき

脆弱性対策以外にも Prisma の experimental な機能についてなど気をつけたい点はあるので
Blitz.js と Prisma と Next.js のドキュメントは読んでおいた方が良さそうです

脚注
  1. https://www.reddit.com/r/programminghorror/comments/66klvc/this_javascript_code_powers_a_1500_user_intranet/ を参考にしています ↩︎

  2. @prisma/cli v2.9.0 で実行しました ↩︎

  3. 一部のみのせています ↩︎

  4. ログインしていないユーザは蹴るようになっているので悪意あるユーザがログインできないならこれでも問題ないかもしれません ↩︎

  5. 好みに応じて ajv になどに差し替えても良いと思います ↩︎