栽培管理アプリを作ってます⑮-1:デプロイ前のセキュリティチェック

目次

はじめに

前回、完成したと思ったら色々問題があった話をしました。(今後も問題があるかもしれませんが)

もうデプロイしちゃいました(栽培アプリはこちらから)が、デプロイ前のセキュリティチェックも実施してますので、書き記しておこうと思います。


なぜセキュリティチェックが必要か

実際に起きるリスク

1. ユーザーデータの漏洩

  • 他のユーザーの圃場データが見えてしまう
  • 作業記録が筒抜け
  • 個人情報(メールアドレスなど)が漏洩

2. 不正なデータ操作

  • 他人の作業記録を削除される
  • データを改ざんされる
  • システムを破壊される(DROP TABLEなど)

3. アカウント乗っ取り

  • 認証トークンが盗まれる
  • 他人になりすまされる

個人開発でも、ユーザーの信頼を守る責任があります。

基本的なセキュリティ対策は、最低限のマナーだと思って取り組みました。


セキュリティチェックリスト(6項目)

実際にチェックした項目は以下の6つです。

  1. ✅ RLSポリシーの確認
  2. ✅ XSS(クロスサイトスクリプティング)対策
  3. ✅ SQLインジェクション対策
  4. ✅ トークン管理(Cookie設定)
  5. ✅ CORS設定の確認
  6. ✅セキュリティヘッダーの設定

それぞれ詳しく見ていきます。


1. RLSポリシーの確認

何をチェック?

Row Level Security(RLS)は、Supabaseの最重要セキュリティ機能です。

  • 全テーブルでRLSが有効になっているか
  • ポリシーの条件が正しいか
  • {public} vs {authenticated} の使い分け

確認方法

Supabase Dashboard → SQL Editorで以下を実行:

SELECT 
  tablename,
  policyname,
  cmd,
  roles
FROM pg_policies 
WHERE schemaname = 'public' 
  AND tablename LIKE 'cult_%'
  AND cmd IN ('INSERT', 'UPDATE', 'DELETE')
ORDER BY tablename, cmd;

発見した問題

全てのポリシーが {public} になっていました ❌

問題点

{public} はPostgreSQLの「全員に適用」ロールです。ログイン済みかどうかに関わらず適用対象になるため、ポリシーの条件次第では意図しない公開につながりやすい設定です。

正しい設定

{authenticated} = ログイン済みユーザーのみ

-- 全テーブルのINSERT/UPDATE/DELETEポリシーを修正
ALTER POLICY "Users can insert own beds" ON cult_beds TO authenticated;
ALTER POLICY "Users can update own beds" ON cult_beds TO authenticated;
ALTER POLICY "Users can delete own beds" ON cult_beds TO authenticated;

-- 以下、全テーブルで同様に修正...

なぜこの違いが重要か

実際のリスク比較:

設定ログイン前ログイン後リスク
{public}適用対象になる(条件次第)操作可能❌ 高
{authenticated}適用外操作可能✅ 低

たとえ条件式(USING句)で auth.uid() をチェックしていても:

  • 将来のポリシー追加時に USING (true) を書いてしまう
  • 意図しない穴が開く可能性

{authenticated} なら、この種のミスを防げます。


2. XSS(クロスサイトスクリプティング)対策

何をチェック?

ユーザーが入力した内容が、そのままJavaScriptとして実行されないかを確認します。

テスト方法

作業記録のメモ欄に、悪意のあるコードを入力してみます:

<script>alert('XSS test')</script>

結果

<script>alert('XSS test')</script> というテキストが表示されました。

JavaScriptとして実行されていません ✅

なぜ安全だったのか

React の自動エスケープ機能

Reactは、デフォルトで全ての変数を安全な文字列に変換します:

// これは安全
<p>{log.memo}</p>

// 危険(使ってはいけない)
<p dangerouslySetInnerHTML={{ __html: log.memo }} />

dangerouslySetInnerHTML を使わない限り、XSSは発生しません。

なぜ安全だったのか

React の自動エスケープ機能

Reactは、デフォルトで全ての変数を安全な文字列に変換します:

// これは安全
<p>{log.memo}</p>

// 危険(使ってはいけない)
<p dangerouslySetInnerHTML={{ __html: log.memo }} />

通常のReactのJSX埋め込みでは自動エスケープが働くため、今回試した入力経路では実行されませんでした。

ただし、dangerouslySetInnerHTML や innerHTML、危険な外部ライブラリの利用など、別の経路からXSSが発生する可能性は残ります。そのため、ReactのエスケープだけではすべてのXSSを防げるわけではなく、後述するCSP(Content-Security-Policy)によるブラウザレベルの防御も組み合わせています。


3. SQLインジェクション対策

何をチェック?

ユーザーが入力した内容が、そのままSQLとして実行されないかを確認します。

テスト方法

作業記録のメモ欄に、SQL文を入力してみます:

'; DROP TABLE users--

これはSQLインジェクションのテストでよく使われる文字列です。

結果

'; DROP TABLE users-- というただの文字列として保存されました。

SQLとして実行されていません ✅

なぜ安全だったのか

Supabase のクエリビルダーを使用しています。

// 安全
const { data } = await supabase
  .from('cult_logs')
  .insert({ memo: userInput });

// 危険(使ってはいけない)
await supabase.rpc('execute_raw_sql', { sql: userInput });

この方法では、ユーザー入力は SQLの命令ではなくデータとして扱われます。
Supabaseのクエリビルダーを使用している限り、ユーザー入力はSQL文ではなくパラメータとして処理されるため、 SQLインジェクションのリスクは低くなります。

もし対策されていない場合

もしユーザー入力をそのままSQLに組み込んでしまうと、次のようなことが起こる可能性があります。

-- 本来のSQL
INSERT INTO cult_logs (memo) VALUES ('ユーザー入力');

-- 攻撃された場合
INSERT INTO cult_logs (memo) VALUES (''); DROP TABLE users--');
                                      ↑ここで文字列終了
                                         ↑テーブル削除コマンド実行
                                                         ↑以降をコメントアウト

この場合:

  • users テーブルが削除される
  • 全ユーザーデータが消失
  • アプリが完全に動かなくなる

と、いった深刻な問題につながります。

チェックポイント:

SQLインジェクションを防ぐために、次の点を確認しました。

  • ユーザー入力を 直接SQL文に埋め込んでいない
  • Supabaseの クエリビルダーを使用している

今回のアプリでは、この条件を満たしているため SQLインジェクションのリスクは低い構成になっています。


4. トークン管理(Cookie設定)

何をチェック?

認証トークンが安全に保存されているかを確認します。

理想的な状態:

  • ✅ HttpOnly Cookie に保存
  • ✅ JavaScriptからアクセス不可

確認方法

ブラウザの開発者ツールで確認します:

  1. F12 を押して開発者ツールを開く
  2. Application タブ → Cookies
  3. HttpOnly 列を確認

発見した問題

HttpOnly 列が空欄でした。

JavaScriptから document.cookie でアクセスできる状態です。

これは問題?

結論:この構成では、やむを得ない面があります

実は、この状態には理由があります。

構成上の理由

今回のアプリはブラウザからSupabaseへ直接アクセスする構成を採用しています。そのため、ブラウザ側でセッション管理が必要になり、一般的な「HttpOnly Cookieのみで完結する認証構成」とは少し異なる仕組みになっています。

Supabase公式の立場

Supabaseの公式ドキュメントでは、HttpOnly Cookieについて次のように説明されています:

This is not necessary. Both the access token and refresh token are designed to be passed around to different components in your application.(HttpOnly Cookie にする必要はありません。アクセストークンとリフレッシュトークンは、アプリ内の様々なコンポーネントで扱われることを前提に設計されています。)

つまり、Supabaseのブラウザ構成においてHttpOnlyを必須にしない設計は、公式に認められたアプローチです。

ただし「盗まれても平気」ではない

これは「トークンが盗まれても安全」という意味ではありません。JavaScriptからトークンにアクセスできる以上、XSSによってトークンが盗まれるリスクは残ります。盗まれたトークンを使えば、本人の権限でデータを操作される可能性があります。RLSは「他人のデータへのアクセスを防ぐ」仕組みですが、本人権限での不正操作まではカバーできません。

だからこそ、2番で説明したXSS対策と、後述するCSPによる多層防御が重要になります。

より安全にする方法(将来の課題)

全ての認証処理を Server Action 化することで、HttpOnly Cookieを使用できます:

// server/actions.ts
'use server'

import { createServerClient } from '@supabase/ssr'

export async function login(email: string, password: string) {
  const supabase = createServerClient(...)
  return await supabase.auth.signInWithPassword({ email, password })
}

今回は実装コストの観点から見送りましたが、将来的な課題として認識しています。


5. CORS設定の確認

今回の構成ではNext.js側にCORS設定は不要

今回のアプリの構成は次のようになっています:

ブラウザ
↓
Supabase API
↓
DB

この構成では、CORS設定はSupabase側が管理しています。Next.jsはAPIサーバーとして動作しているわけではないため、CORS設定を書く場所がそもそもありません

では、どんなときにCORS設定が必要になるのか?

Next.jsをAPIサーバーとして使う場合です。たとえば:

1. 秘密情報をブラウザに渡したくないとき 外部APIキーや決済用の秘密鍵などはブラウザに置けません。そのため:

ブラウザ → Next.js → 外部API

Stripeの決済やOpenAI API呼び出しなどがこれにあたります。

2. 入力チェックや権限制御をサーバー側でやりたいとき ログイン確認・管理者チェック・バリデーションなどをNext.js側で行う場合です。

3. 複数の外部サービスをまとめたいとき

ブラウザ → Next.js → Supabase / Google Sheets / メール送信

登録ボタン1回でDB保存・通知メール送信をまとめてやる場合などです。

今回はこれらに該当しないため、設定は不要でした。構成が変わる場合は改めて確認が必要です。

認証リダイレクト設定の確認

あわせて、Supabase Dashboard → Authentication → URL Configurationの設定も確認しました。これはCORSとは別の話で、認証フローの正しい遷移先を設定するものです。

  • Site URL:パスワードリセットメールのリンク先など
  • Redirect URLs:認証後にリダイレクトを許可するURL一覧

今回はメール/パスワード認証のみのため、設定しなくても動作します。ただし将来的にOAuthやMagic Linkを追加する場合は設定が必要になるため、今のうちに確認・設定しておきました。

なお、構成が変わる場合(Next.jsをAPIサーバーとして使う場合など)はCORS設定が必要になるため、改めて確認が必要です。


6. セキュリティヘッダーの設定

XSS対策の「一次防御」と「追加防御」

XSS対策としては、前の章で説明したReactの自動エスケープが基本になります。ユーザー入力をそのままHTMLとして表示しないことで、多くのXSSは防ぐことができます。

ただし、ソフトウェアにはバグが入り込む可能性があります。もし将来どこかで不注意な実装が入り、不正なJavaScriptがページに混入してしまった場合を考えると、もう一段の防御が欲しくなります。

そこで設定したのが Content Security Policy(CSP) です。

CSPはXSSそのものを防ぐ仕組みではなく、万が一不正なスクリプトが実行された場合でも「外部サイトへの通信」や「外部スクリプトの読み込み」を制限することで、被害を広げにくくする仕組みです。

今回の設定と限界

今回の設定では:

script-src 'self' 'unsafe-inline'

この設定ではインラインスクリプト(HTML内の <script>)は許可されています。そのため、CSPだけでXSSを完全に防ぐことはできません。

ただし、

  • 外部サイトからのスクリプト読み込み
  • 外部ドメインへの通信

などは制限されるため、万が一不正コードが混入しても被害の拡大を抑える効果があります。

多層防御のまとめ

このアプリでは次のような多層防御になっています:

  1. Reactの自動エスケープ → ユーザー入力によるXSSを防ぐ(一次防御)
  2. 危険なAPIを使わない → dangerouslySetInnerHTML などを使用しない
  3. CSP(ブラウザ側の制御) → 万が一の不正コード実行時でも被害を広げにくくする

複数の防御を組み合わせることで、リスクを下げています。

セキュリティヘッダーの実装

Next.jsでは next.config.ts に設定を書くことで、全ページにセキュリティヘッダーを追加できます。

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  reactCompiler: true,

  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          // クリックジャッキング対策
          { key: "X-Frame-Options", value: "DENY" },

          // MIMEスニッフィング対策
          { key: "X-Content-Type-Options", value: "nosniff" },

          // 参照元(リファラ)の漏れを抑える
          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },

          // 権限系(カメラ・位置情報など)を基本OFF
          {
            key: "Permissions-Policy",
            value: [
              "accelerometer=()",
              "camera=()",
              "geolocation=()",
              "gyroscope=()",
              "magnetometer=()",
              "microphone=()",
              "payment=()",
              "usb=()",
              "interest-cohort=()",
            ].join(", "),
          },

          // CSP(追加防御入り)
          {
            key: "Content-Security-Policy",
            value: [
              "default-src 'self'",
              "base-uri 'self'",
              "object-src 'none'",
              "frame-ancestors 'none'",
              "form-action 'self'",
              "script-src 'self' 'unsafe-inline'",
              "style-src 'self' 'unsafe-inline'",
              "img-src 'self' data: https:",
              "font-src 'self' data:",
              "connect-src 'self'",
              "worker-src 'self' blob:",
            ].join("; "),
          },
        ],
      },
    ];
  },
};

export default nextConfig;

各ヘッダーの意味

① X-Frame-Options: DENY

意味:「このサイトをiframeで埋め込むな」

防げる攻撃:クリックジャッキング

具体例:悪意のあるサイトがあなたのログインボタンを透明なiframeで覆い、ユーザーに気づかせずクリックさせる攻撃を防ぎます。

② X-Content-Type-Options: nosniff

意味:「ブラウザさん、勝手にファイルの種類を推測しないで」

防げる攻撃:MIMEスニッフィング攻撃

具体例:画像ファイルのふりをしたJavaScriptが実行されるのを防ぎます。

③ Referrer-Policy

意味:「どこから来たかの情報は、必要最小限だけ教えて」

防げる問題:URLに含まれる個人情報の漏洩

④ Permissions-Policy

意味:「カメラ・マイク・位置情報などのブラウザ機能をOFFにする」

防げる問題:不要なデバイス機能への不正アクセス

⑤ Content-Security-Policy(CSP)

設定意味
default-src 'self'基本は自分のサイトからだけ読み込む
base-uri 'self'<base>タグの悪用を防ぐ
object-src 'none'FlashなどのプラグインをすべてNG
frame-ancestors 'none'他サイトからのiframe埋め込みを禁止
form-action 'self'フォームの送信先を自分のサイトに限定

外部サービスを使う場合の追加設定

Supabase を使う場合:

"connect-src 'self' https://*.supabase.co",

Google Analytics(GA4) を使う場合:

"script-src 'self' 'unsafe-inline' https://www.googletagmanager.com",
"img-src 'self' data: https: https://www.google-analytics.com",
"connect-src 'self' https://www.google-analytics.com https://region1.google-analytics.com",

もしブラウザのコンソールに

Refused to load because of Content Security Policy

というエラーが出た場合は、CSPによってブロックされています。エラーに表示されているドメインを該当の src に追加してください。


まとめ

対策内容
① RLSポリシーauthenticated に統一し、意図しない公開を防ぐ
② XSS対策Reactの自動エスケープ(一次防御)+CSPで多層化
③ SQLインジェクション対策確認した経路ではクエリビルダーで安全。生SQL追加時は要注意
④ トークン管理構成上HttpOnly不可。XSS対策とCSPで補完
⑤ CORS設定この構成ではNext.js側に設定箇所なし。Supabase側が管理
⑥ セキュリティヘッダーCSPで外部リソース制限。被害拡大防止の追加防御

フレームワークに感謝

今回のセキュリティ対策の大部分は、Next.js と Supabase のおかげです。

自動的に守られていること

  • XSS対策(Reactの自動エスケープ)
  • SQLインジェクション対策(Supabaseのクエリビルダー)
  • HTTPS化(Vercelの自動証明書)
  • 認証トークン管理(Supabase Auth)

※ただし、HTMLを直接挿入する場合や生SQLを使う場合は別途対策が必要です。

自分で設計したこと(重要)

  • RLSポリシーの設計(最重要)
  • Content-Security-Policy の追加
  • X-Frame-Options などのヘッダー追加

モダンなフレームワークは「基礎体力」を守ってくれます。その上で、アプリ固有のセキュリティ(RLSなど)を自分で設計する。これが個人開発でも安全なアプリを作るコツだと学びました。


デプロイ手順(簡潔に)

1. Supabaseプロジェクト作成

  1. https://supabase.com/dashboard にアクセス
  2. “New project” をクリック
  3. 以下を設定:
    • Namecrop-tracker-demo
    • RegionNortheast Asia (Tokyo)
    • Pricing PlanFree

2. データベース移行

テーブル構造のコピー

ローカル Supabase → Table Editor

各テーブルを右クリック → Copy table schema → デモ版で実行

RLSポリシーの適用

ローカルで設定した全ポリシーをデモ版にも適用

関数の作成

-- my_org_id() 関数
CREATE OR REPLACE FUNCTION public.my_org_id()
RETURNS uuid
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
  SELECT organization_id
  FROM public.cult_workers
  WHERE user_id = auth.uid()
  LIMIT 1
$function$;

GRANT EXECUTE ON FUNCTION public.my_org_id() TO authenticated;

-- my_worker_id() 関数も同様に作成

3. Vercel デプロイ

  1. https://vercel.com にアクセス
  2. Add New… → Project
  3. crop-cultivation-app をインポート
  4. 以下を設定:

Build Commandpnpm run build

Install Commandpnpm install

Environment Variables:

NEXT_PUBLIC_SUPABASE_URL=デモ版のProject URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=デモ版のanon key
SUPABASE_SERVICE_ROLE_KEY=デモ版のservice_role key
NEXT_PUBLIC_DEMO_MODE=true
  1. Deploy をクリック

※デプロイ完了しております。→ 栽培管理アプリのデモはこちら

目次