0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React/Supabase】ログイン不要の「公開お問い合わせ・課題受付フォーム」を安全に運用する設計 ― RLS・SECURITY DEFINER・ハニーポットを組み合わせた堅牢化パターン

0
Last updated at Posted at 2026-10-27

こんにちは。プロジェクト管理SaaS「Taski(タスキ)」を個人開発しているエンジニアです。

BtoB SaaSやプロジェクト管理ツールを運用していると、必ずぶつかる要望があります。

「取引先や社外クライアント、他部署から『タスクや不具合報告』を送ってもらいたいけれど、そのためだけに全員にアカウントを作らせるのはハードルが高すぎる……」
「Googleフォームやメールで受け取って、手作業でチケットを転記・コピーするのは二度手間で限界がある……」

この課題を解決するのが、専用URLを共有するだけで誰でも課題を直接起票できる「パブリック受付フォーム(Intake Form)」機能です。

しかし、BtoBのデータベース(Supabase / PostgreSQL)に対して、「未認証(anon)の一般ユーザーからログイン不要でデータを書き込ませる」というのは、一歩間違えると重大なセキュリティインシデントに直結します。

ありがちな大事故パターン:
  ❌ anonロールに tickets テーブルへの INSERT 権限を与えてしまう
  ❌ 全件SELECTが通って他社の機密プロジェクトや個人情報がダダ漏れ
  ❌ スパムBOTに秒間数百件のゴミチケットを連投されてDBとStorageがパンク
  ❌ RLS(Row Level Security)の穴を突かれて他人のチケットが改ざん・削除される

Taski では、「アカウント不要の利便性」と「強固なゼロトラスト・セキュリティ」を両立するため、Supabase RLS・PostgreSQLの SECURITY DEFINER 関数・ハニーポット・ブラウザ指紋レートリミットを多層防御で組み合わせたアーキテクチャを採用しました。

本記事では、この受付フォーム機能のアーキテクチャ設計と実装コードをすべて公開・解説します。


1. 全体アーキテクチャ:なぜテーブル直接INSERTを禁じるのか

まず、全体のデータフローと防御境界です。

鉄則:anon ロールにはテーブル権限を一切与えない

Supabase の標準的なチュートリアルでは、anon(未ログインロール)に対して CREATE POLICY "anon insert" ON tickets FOR INSERT WITH CHECK (...) のようなポリシーを書きがちです。

しかし、チケットテーブルにはプロジェクトID、顧客名、社内コメント、添付ファイルなど秘匿情報が集約されています。anon に対してテーブルへの直接アクセスを1ミリでも許可すると、将来的なスキーマ変更やRLSの書き換え時に思わぬ情報漏洩の火種になります。

そこで、以下のルールを徹底しました:

  1. tickets および project_intake_forms テーブルの直接操作は、ログインユーザー(authenticated)かつプロジェクト管理者に限定する。
  2. 未認証ユーザーの入り口は、2つの厳格な PostgreSQL RPC(SECURITY DEFINER 関数)だけに絞り込む。
    • get_public_intake_form(target_token): フォーム表示用のメタデータ取得(プロジェクト名、受付タイトル、説明文など)
    • submit_public_intake_form(target_token, ...): チケット起票処理
  3. フォームのURLには、推測不可能な 24バイト(48文字)の暗号論的乱数トークン(gen_random_bytes(24))を使用する。

2. DB設計:設定テーブルと厳格なRLS

まずは、プロジェクトごとに受付フォームの設定を保持するテーブルです。

-- 1. 受付フォーム設定テーブル
CREATE TABLE IF NOT EXISTS public.project_intake_forms (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id UUID NOT NULL UNIQUE REFERENCES public.projects(id) ON DELETE CASCADE,
  token TEXT NOT NULL UNIQUE,
  is_enabled BOOLEAN NOT NULL DEFAULT true,
  title TEXT NOT NULL DEFAULT 'ご要望・お問い合わせ受付フォーム',
  description TEXT DEFAULT 'こちらからご要望や不具合報告を送信していただけます。',
  success_message TEXT DEFAULT 'お問い合わせを受け付けました。',
  default_status TEXT NOT NULL DEFAULT 'open',
  default_priority TEXT NOT NULL DEFAULT 'medium',
  default_assignee_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
  default_tag_ids UUID[] DEFAULT '{}',
  require_name BOOLEAN NOT NULL DEFAULT false,
  require_email BOOLEAN NOT NULL DEFAULT false,
  allow_attachments BOOLEAN NOT NULL DEFAULT true,
  created_by UUID REFERENCES public.profiles(id) ON DELETE SET NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW())
);

-- RLSを有効化
ALTER TABLE public.project_intake_forms ENABLE ROW LEVEL SECURITY;

-- プロジェクトの管理者(admin)のみが設定を閲覧・更新できる
CREATE POLICY "Allow admins to manage their project's intake form" ON public.project_intake_forms
  FOR ALL
  USING (
    EXISTS (
      SELECT 1 FROM public.project_members pm
      WHERE pm.project_id = project_intake_forms.project_id 
        AND pm.user_id = auth.uid() 
        AND pm.role = 'admin'
    )
  );

ポイントは、フォームから起票されたチケットに付与される初期ステータス、優先度、担当者、タグをプロジェクト管理者がGUIから柔軟に指定できる点です。
「社外からの問い合わせは、自動的に『未仕分け』タグを付け、サポート担当のAさんに割り振る」といった運用がコード修正なしで完結します。


3. 未認証アクセスを安全に捌く SECURITY DEFINER 関数

未認証ユーザーは project_intake_forms も tickets も直接 SELECT / INSERT できません。
代わりに、トークンが一致した行のみを処理する特権ストアドプロシージャ(SECURITY DEFINER)を定義します。

① フォーム情報取得 RPC (get_public_intake_form)

CREATE OR REPLACE FUNCTION public.get_public_intake_form(target_token TEXT)
RETURNS JSONB AS $$
DECLARE
  form_row RECORD;
  proj_row RECORD;
  result JSONB;
BEGIN
  -- トークンが一致し、有効化されているフォームを1件取得
  SELECT * INTO form_row
  FROM public.project_intake_forms
  WHERE token = target_token AND is_enabled = true;

  IF NOT FOUND THEN
    RETURN jsonb_build_object('success', false, 'error', 'フォームが見つからないか、無効化されています。');
  END IF;

  -- 紐づくプロジェクトの公開用情報のみを取得(内部IDや社内設定は伏せる)
  SELECT name, icon INTO proj_row
  FROM public.projects
  WHERE id = form_row.project_id;

  result := jsonb_build_object(
    'success', true,
    'form', jsonb_build_object(
      'title', form_row.title,
      'description', form_row.description,
      'project_name', proj_row.name,
      'project_icon', proj_row.icon,
      'require_name', form_row.require_name,
      'require_email', form_row.require_email,
      'allow_attachments', form_row.allow_attachments,
      'success_message', form_row.success_message
    )
  );

  RETURN result;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

内部の project_id や default_assignee_id などの機密カラムは一切フロントに返さず、表示に必要な情報だけを JSONB に成形して返しているのがポイントです。


② チケット起票 RPC (submit_public_intake_form) とハニーポット

続いて、フォーム送信時の本体関数です。

CREATE OR REPLACE FUNCTION public.submit_public_intake_form(
  target_token TEXT,
  title_input TEXT,
  description_input TEXT,
  reporter_name TEXT DEFAULT NULL,
  reporter_email TEXT DEFAULT NULL,
  attachments_input JSONB DEFAULT '[]'::jsonb,
  honeypot TEXT DEFAULT NULL -- 🍯 BOT対策用
)
RETURNS JSONB AS $$
DECLARE
  form_row RECORD;
  new_ticket_id UUID;
  new_ticket_number INT;
  full_description TEXT;
BEGIN
  -- 1. ハニーポット検証(値が入っていればBOTと判定してエラー)
  IF honeypot IS NOT NULL AND LENGTH(TRIM(honeypot)) > 0 THEN
    -- BOTには成功したように見せかけて早期リターンするか、400を返す
    RAISE EXCEPTION '不正なリクエストが検出されました。';
  END IF;

  -- 2. トークン照合
  SELECT * INTO form_row
  FROM public.project_intake_forms
  WHERE token = target_token AND is_enabled = true;

  IF NOT FOUND THEN
    RAISE EXCEPTION 'フォームが見つからないか、送信が締め切られています。';
  END IF;

  -- 3. 入力バリデーション
  IF title_input IS NULL OR LENGTH(TRIM(title_input)) = 0 THEN
    RAISE EXCEPTION 'タイトルは必須です。';
  END IF;

  IF form_row.require_name AND (reporter_name IS NULL OR LENGTH(TRIM(reporter_name)) = 0) THEN
    RAISE EXCEPTION 'お名前を入力してください。';
  END IF;

  IF form_row.require_email AND (reporter_email IS NULL OR LENGTH(TRIM(reporter_email)) = 0) THEN
    RAISE EXCEPTION 'メールアドレスを入力してください。';
  END IF;

  -- 4. チケット説明文にお問い合わせ元情報を付与
  full_description := COALESCE(description_input, '');
  IF reporter_name IS NOT NULL OR reporter_email IS NOT NULL THEN
    full_description := full_description || E'\n\n---\n**【お問い合わせ元】**\n'
      || COALESCE('お名前: ' || reporter_name || E'\n', '')
      || COALESCE('ご連絡先: ' || reporter_email || E'\n', '')
      || '送信日時: ' || TO_CHAR(NOW() AT TIME ZONE 'Asia/Tokyo', 'YYYY-MM-DD HH24:MI:SS (JST)');
  END IF;

  -- 5. チケットをプロジェクトに起票(設定テーブルの初期値を適用)
  INSERT INTO public.tickets (
    project_id,
    title,
    description,
    status,
    priority,
    assignee_ids,
    tag_ids,
    attachments,
    created_by
  ) VALUES (
    form_row.project_id,
    TRIM(title_input),
    full_description,
    form_row.default_status,
    form_row.default_priority,
    CASE WHEN form_row.default_assignee_id IS NOT NULL 
         THEN ARRAY[form_row.default_assignee_id] 
         ELSE '{}'::uuid[] END,
    COALESCE(form_row.default_tag_ids, '{}'::uuid[]),
    COALESCE(attachments_input, '[]'::jsonb),
    form_row.created_by
  )
  RETURNING id, ticket_number INTO new_ticket_id, new_ticket_number;

  RETURN jsonb_build_object(
    'success', true,
    'ticket_number', new_ticket_number,
    'message', form_row.success_message
  );
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

4. フロントエンド実装:ハニーポットとUX

React側では、一般的なユーザーには見えず、DOM解析型のBOTだけが引っかかるハニーポットフィールド(Honeypot)をフォーム内に仕込みます。

ハニーポットのCSSトリック

export const PublicIntakeFormPage: React.FC = () => {
  const [formData, setFormData] = useState({
    title: '',
    description: '',
    reporterName: '',
    reporterEmail: '',
    // 🍯 ハニーポット用(人間には見えないが、BOTは自動入力してしまう項目)
    website_hp_confirm: '',
  });

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      {/* 🍯 ハニーポットフィールド */}
      <div 
        aria-hidden="true" 
        style={{ 
          opacity: 0, 
          position: 'absolute', 
          top: 0, 
          left: 0, 
          height: 0, 
          width: 0, 
          zIndex: -1, 
          overflow: 'hidden' 
        }}
      >
        <label htmlFor="website_hp_confirm">このフィールドは空欄のままにしてください</label>
        <input
          type="text"
          id="website_hp_confirm"
          name="website_hp_confirm"
          tabIndex={-1}
          autoComplete="off"
          value={formData.website_hp_confirm}
          onChange={(e) => setFormData(prev => ({ ...prev, website_hp_confirm: e.target.value }))}
        />
      </div>

      {/* 通常の入力フォーム */}
      <div>
        <label className="block text-sm font-semibold text-slate-700">件名・タイトル *</label>
        <input
          type="text"
          required
          value={formData.title}
          onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
          className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:ring-1 focus:ring-sky-500"
          placeholder="ご要望や不具合の概要を入力してください"
        />
      </div>
      {/* ... その他フィールド ... */}
    </form>
  );
};

なぜ display: none だけでは不十分なのか?

最新の賢いBOTクローラーは、CSSの display: none や visibility: hidden が当たっている input を検知して入力をスキップします。
そのため、opacity: 0 かつ width: 0, height: 0, overflow: hidden, position: absolute というスタイルを組み合わせることで、画面上のレイアウトを崩さずにBOTだけを確実にトラップに嵌めることができます。

送信時、フロント側でも if (formData.website_hp_confirm) return; で弾きつつ、万が一すり抜けてAPIを直接叩かれた場合も前述の PostgreSQL 関数側で弾く二重防御になっています。


5. 運用してみて分かった3つの落とし穴と対処法

① トークン漏洩時の「即時ローテーション(再発行)」

取引先との契約終了や、社外フォームのURLが意図せず流出した場合に備え、管理画面から1クリックでトークンを破棄・再生成できる関数を用意しました。

CREATE OR REPLACE FUNCTION public.regenerate_intake_form_token(target_project_id UUID)
RETURNS TEXT AS $$
DECLARE
  new_token TEXT;
BEGIN
  -- 管理者のみ実行可能
  IF NOT EXISTS (
    SELECT 1 FROM public.project_members pm
    WHERE pm.project_id = target_project_id AND pm.user_id = auth.uid() AND pm.role = 'admin'
  ) THEN
    RAISE EXCEPTION '権限がありません';
  END IF;

  new_token := encode(gen_random_bytes(24), 'hex');

  UPDATE public.project_intake_forms
  SET token = new_token, is_enabled = true, updated_at = NOW()
  WHERE project_id = target_project_id;

  RETURN new_token;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

古いURLは即座に 404(無効)になり、攻撃者にURLを特定されても即座に無力化できます。

② 添付ファイルのStorageセキュリティ

未ログインのユーザーに画像やログファイルを添付させる場合、Supabase Storage のバケットポリシーにも注意が必要です。

  • アップロードは許可するが、ファイルの「一覧取得(SELECT)」や「他人のファイルの削除(DELETE)」は anon に絶対に許可しない。
  • アップロード可能な拡張子(png, jpg, pdf, txt, zip等)とファイルサイズ上限(5MB以下)をクライアントとStorageの両側でバリデーションする。

③ チケット起票時の通知とリアルタイム反映

フォームからチケットが起票された瞬間、社内の担当者が気づけなければ意味がありません。
Taski では Supabase の Database Webhook / Realtime 購読により、起票された瞬間に社内メンバーのカンバン画面にアニメーション付きでリアルタイム挿入され、Slack / メールへ即時通知が飛ぶパイプラインを繋いでいます。


まとめ:オープンな利便性とゼロトラストの両立

SaaSにおける「未認証フォーム」の実装は、利便性とセキュリティが常にトレードオフになりがちです。
しかし、設計の境界を明確にし:

  1. テーブルへの直接アクセス権を誰にも(特にanonに)渡さない
  2. 入出力が100%制御された SECURITY DEFINER 関数だけを公開窓口にする
  3. 推測不可能な暗号乱数トークン + ハニーポットによる多層防御を行う

というパターンを徹底することで、安全性を一切犠牲にすることなく、社外からのチケット自動起票を実現できます。


実プロダクトでの動作を体験できます

本記事で解説した「パブリック受付フォーム」や、リアルタイムに同期するカンバン・ガントチャート機能は、筆者が開発している Taski(タスキ) のデモ環境で今すぐ実際に動かして試すことができます。

アカウント登録・クレジットカード登録は一切不要で、1秒でデモ画面が起動します。
「社外とのタスク共有を安全かつ高速に行いたい」という方は、ぜひ実際の挙動を体験してみてください!

👉 Taski のデモを試してみる(登録不要・1秒で起動)

0
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?