開発日誌 #40

セキュリティ全直し+UX改善スプリント
macOS公証・XSS対策・HttpOnly Cookie

📅 2026-03-08 ✍️ 浜田雄希 ⏱ 読了約8分 #セキュリティ #UX #macOS
ブログ一覧へ

📋 目次

  1. 今回のスプリント概要
  2. セキュリティ監査:8つの問題を発見
  3. 修正① HSTS + CSP ヘッダーを追加
  4. 修正② JWT を localStorage から追い出す
  5. 修正③ XSS パッチ(innerHTML → DOM API)
  6. 修正④ その他5項目
  7. UX改善:インストーラー・ログアウト・自動ペアリング
  8. macOS アプリの署名と公証(notarization)
  9. まとめ

今回のスプリント概要

ミセバンAI v1 のリリース後、「セキュリティ的に問題ないか調べてほしい」というオーダーが入った。Claude Code で監査を走らせたところ、8つの問題が見つかった。すべて「全部直して」の一言で修正スプリントが始まった。

同時並行で、インストーラーのUX改善・macOS アプリの Gatekeeper 署名・ランディングページからダッシュボードへのメール引き回しなど UX 系の修正もまとめて対応した。

セキュリティ監査:8つの問題を発見

監査の結果、以下の問題が洗い出された。

修正済
① HSTS ヘッダーが未設定
HTTPS を強制する Strict-Transport-Security が nginx に存在しなかった。中間者攻撃(HTTP ダウングレード)のリスクあり。
修正済
② CSP ヘッダーが未設定
Content-Security-Policy なし。外部スクリプトの任意読み込みが可能な状態だった。
修正済
③ JWT を localStorage に保存(XSS で盗難可能)
最も重大な問題。JavaScript から読み取り可能な localStorage に JWT を保存していた。HttpOnly Cookie に移行することで JS からアクセス不能にした。
修正済
④ innerHTML への未サニタイズ値の挿入(XSS)
アラート一覧と API トークン一覧の描画で、APIレスポンスの値を innerHTML に直接埋め込んでいた。攻撃者がストアデータを改ざんすれば任意スクリプトを実行できた。
修正済
⑤ JWT 有効期限が 90 日
トークン漏洩時のウィンドウが大きすぎた。30 日に短縮。
修正済
⑥ 認証エンドポイントにレート制限なし
send-otp に上限がなく、メールスパム攻撃が可能だった。10分間に5回までの制限をインメモリで実装。
修正済
⑦ マジックリンクのURLがハードコード
https://misebanai.com がソースに埋め込まれており、ステージング環境で使えなかった。BASE_URL 環境変数に外出し。
修正済
⑧ ログアウトエンドポイントが存在しない
Cookie を使う設計に変更したため、サーバー側で Cookie をクリアする POST /api/v1/auth/logout が必要になった。

修正① HSTS + CSP ヘッダーを追加

nginx.conf のサーバーブロックに2行追加した。

add_header Strict-Transport-Security
  "max-age=63072000; includeSubDomains; preload" always;

add_header Content-Security-Policy
  "default-src 'self';
   script-src 'self' 'unsafe-inline' 'unsafe-eval'
     https://cdn.jsdelivr.net https://www.googletagmanager.com ...;
   frame-ancestors 'none';
   base-uri 'self';
   form-action 'self';" always;

既存コードがインラインスクリプトを多用しているため unsafe-inline は外せなかった。ただし frame-ancestors 'none'(クリックジャッキング防止)・base-uri 'self'form-action 'self' だけでも十分な防御層になる。

ブラウザセキュリティの鉄則として、セッショントークンは HttpOnly Cookie に保持すべきだ。JS が document.cookie で読めないため、XSS が成立しても盗まれない。

Rust 側(auth.rs)に Cookie 発行ヘルパーを追加した。

pub fn session_cookie_value(token: &str) -> String {
    format!(
        "miseban_session={}; HttpOnly; Secure; \
         SameSite=Lax; Path=/api; Max-Age=2592000",
        token
    )
}

login / signup / magic_login / verify_otp の全4エンドポイントが Set-Cookie ヘッダーを返すように変更。AuthUser エクストラクターは Cookie を優先し、なければ Authorization: Bearer にフォールバックする(後方互換を維持)。

nginx の /api/ リバースプロキシは upstream から来る Set-Cookie をそのままブラウザに転送するため、追加設定なしで動作した。

修正③ XSS パッチ(innerHTML → DOM API)

アラートレンダリングのコードが典型的な XSS 脆弱性を持っていた。

// ❌ 修正前
list.innerHTML = alerts.map(a =>
  `<div>${a.message}</div>`  // a.message が <script> だったら?
).join('');
// ✅ 修正後
alerts.forEach(a => {
  const div = document.createElement('div');
  const text = document.createElement('div');
  text.textContent = a.message;  // textContent は自動エスケープ
  div.appendChild(text);
  list.appendChild(div);
});

API トークン一覧の onclick="deleteApiToken('${t.id}')" も同様に修正。ID をHTML文字列に直接埋め込む代わりに addEventListener でクロージャに閉じ込めた。

修正④ その他5項目

  • JWT 30日化chrono::Duration::days(90)days(30)
  • OTP レート制限HashMap<String, Vec<Instant>> をインメモリで管理。10分ウィンドウで5回超えたら 400 を返す
  • BASE_URL 環境変数:マジックリンクURLを std::env::var("BASE_URL") で生成
  • logout エンドポイントPOST /api/v1/auth/logoutMax-Age=0 の Cookie を返してセッションを失効
  • apiFetch に credentials: 'include':ブラウザが Cookie を送るために必須

UX改善:インストーラー・ログアウト・自動ペアリング

インストーラー UI

ダウンロードページのエージェントインストール手順が「ターミナルでコマンドを実行」という上級者向けの UI になっていた。これを大きな「DMGをダウンロード」「ZIPをダウンロード」ボタンに変更し、コマンドは <details> の中に折りたたんだ。

ログアウト即反映

「すぐ勝手にログアウトされる」という報告があった。原因は setupSessionCheck() の実装ミスで、API が503を返したとき(Fly.io の sleep from idle)も logout を呼んでいた。

// ❌ 修正前
if (!resp.ok && currentUser) { clearAuth(); }

// ✅ 修正後
if (resp.status === 401 && currentUser) { clearAuth(); }

ネットワークエラーや503は「認証失敗」ではないため、401 のときだけログアウトするように変更した。

ダッシュボードからの自動ペアリング

エージェントが既に起動中であれば、ダッシュボード上の「エージェントに接続」ボタンでローカルエージェント(http://localhost:3939)を直接開き、ペアリングコードを自動入力・自動送信できるようにした。

混合コンテンツ(HTTPS ページから HTTP localhost への fetch)はブラウザにブロックされるため、window.open('http://localhost:3939/?pair=XXXXXX') でナビゲーションとして開き、エージェント側で JS を注入して自動送信する設計にした。

ランディング→ダッシュボードのメール引き回し

トップページのヒーローフォームでメールを入力すると /dashboard/?mode=signup&email=xxx にリダイレクトするが、ダッシュボード側が email パラメータを無視していたため、もう一度メールを入力させられていた。

URLSearchParams で email を読み取り、フォームへの自動入力 + 自動送信(magic link 発行)を実装した。ユーザーはメールアドレスを1回入力するだけでメールボックスに飛べる。

macOS アプリの署名と公証(notarization)

「このアプリケーションにマルウェアが含まれていないことを検証できません」というGatekeeperエラーが報告された。

修正は3段階で行った。

Step 1:Developer ID 署名

codesign --force --deep --options runtime \
  --entitlements entitlements.plist \
  --sign "Developer ID Application: Yuki Hamada (5BV85JW8US)" \
  MisebanAI.app

entitlements には com.apple.security.network.servernetwork.client を指定(ローカルHTTPサーバーに必要)。

Step 2:Apple 公証(notarytool)

xcrun notarytool submit MisebanAI-notarize.zip \
  --keychain-profile "misebanai-notarize" \
  --wait
# → status: Accepted

Step 3:シェルスクリプト → Swift バイナリに置き換え

署名・公証後も「開発元を検証できないため開けません」が出続けた。原因は メイン実行ファイルがシェルスクリプトだったこと。Gatekeeper は .app のランチャーが Mach-O バイナリでないと正しく検証できない。

Swift でコンパイル済みのランチャーバイナリを作成し、シェルスクリプトと置き換えた。

swiftc -O main.swift -o MisebanAI \
  -framework Foundation -framework AppKit

その後再署名・再公証・ステープルを実施。

spctl --assess --type execute --verbose MisebanAI.app
# → MisebanAI.app: accepted
# → source=Notarized Developer ID  ✅

DMG も同様に署名・公証・ステープルして完了。これで Gatekeeper のダイアログは一切出なくなった。

ミセバンAI — 無料で試してみる

今あるカメラをつなぐだけ。来客数・客層・ピーク時間をAIが自動分析。

無料でダッシュボードを開く →

まとめ

今スプリントで対応した内容をまとめる。

カテゴリ対応内容状態
セキュリティHSTS / CSP ヘッダー追加✅ 完了
セキュリティJWT を HttpOnly Cookie へ移行✅ 完了
セキュリティinnerHTML XSS 修正(アラート・APIトークン)✅ 完了
セキュリティJWT 30日化・OTPレート制限・logout EP✅ 完了
UXインストーラーUI(DMGボタン化)✅ 完了
UX誤ログアウトバグ修正✅ 完了
UXダッシュボードからの自動ペアリング✅ 完了
UXランディング→ダッシュボードのメール引き回し✅ 完了
macOSDeveloper ID 署名 + Apple 公証 + ステープル✅ 完了
macOSシェルスクリプト → Swift バイナリランチャー✅ 完了

セキュリティ系は「ベータ版だからまあいいか」と後回しにしがちだが、実ユーザーが使い始めた時点で本番だ。今回まとめて対応できてよかった。

次のスプリントは LINE レポートの精度向上と、カメラ映像のリアルタイムプレビュー機能を予定している。