From d6f7b487d06cd0cceaa1c515dca0bb8003143728 Mon Sep 17 00:00:00 2001 From: posimai Date: Sat, 4 Apr 2026 23:04:20 +0900 Subject: [PATCH] fix: security hardening for commercial release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix OAuth (Google/GitHub) DB column bug: SELECT id → SELECT user_id - Add OAuth CSRF protection via state parameter (Google + GitHub) - Restrict /health endpoint: detailed info requires authentication - Add in-memory rate limiter utility (checkRateLimit) - Add rate limit to passkey login/begin: 10 req/min per IP - Add rate limit to Gemini AI analysis: 50 articles/hour per user - Add rate limit to journal suggest-tags: 10 req/hour per user - Update posimai-dev /api/vps-health proxy to send VPS_API_KEY header Co-Authored-By: Claude Sonnet 4.6 --- posimai-dev/server.js | 6 ++ server.js | 149 ++++++++++++++++++++++++++++++++---------- 2 files changed, 120 insertions(+), 35 deletions(-) diff --git a/posimai-dev/server.js b/posimai-dev/server.js index 4a4b17aa..65bce37d 100644 --- a/posimai-dev/server.js +++ b/posimai-dev/server.js @@ -213,10 +213,16 @@ app.get('/api/vercel-deploys', async (req, res) => { // ── VPS health プロキシ (/api/vps-health) ────────────────────── // ブラウザから直接叩くと自己署名証明書環境でCORSエラーになるためサーバー経由でプロキシ +// VPS_API_KEY を .env に設定すると詳細情報(メモリ・ディスク等)を取得できる app.get('/api/vps-health', async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); try { + const headers = {}; + if (process.env.VPS_API_KEY) { + headers['Authorization'] = `Bearer ${process.env.VPS_API_KEY}`; + } const r = await fetch('https://api.soar-enrich.com/api/health', { + headers, signal: AbortSignal.timeout(6000), }); const data = await r.json(); diff --git a/server.js b/server.js index 8ba96c38..9fc18b53 100644 --- a/server.js +++ b/server.js @@ -43,6 +43,33 @@ setInterval(() => { } }, 10 * 60 * 1000); +// ── 汎用インメモリレートリミッター ────────────────────────────── +// usage: checkRateLimit(store, key, maxCount, windowMs) +// 返り値: true = 制限内、false = 超過 +const rateLimitStores = {}; +function checkRateLimit(storeName, key, maxCount, windowMs) { + if (!rateLimitStores[storeName]) rateLimitStores[storeName] = new Map(); + const store = rateLimitStores[storeName]; + const now = Date.now(); + const entry = store.get(key); + if (!entry || now - entry.windowStart >= windowMs) { + store.set(key, { count: 1, windowStart: now }); + return true; + } + if (entry.count >= maxCount) return false; + entry.count++; + return true; +} +// 定期クリーンアップ(1時間ごと) +setInterval(() => { + const now = Date.now(); + for (const store of Object.values(rateLimitStores)) { + for (const [k, v] of store) { + if (now - v.windowStart > 60 * 60 * 1000) store.delete(k); + } + } +}, 60 * 60 * 1000); + // ── ユーティリティ ─────────────────────────────────────────────────── function escapeHtml(str) { return String(str) @@ -608,8 +635,26 @@ function buildRouter() { const r = express.Router(); // ヘルスチェック(Station コックピット向けに拡張) + // 認証なし: 最小限レスポンス(外部監視ツール向け) + // 認証あり(API Key / JWT): 詳細システム情報を追加 r.get('/health', (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); + const base = { status: 'ok', timestamp: new Date().toISOString() }; + + // 認証確認(失敗しても 401 にせず最小レスポンスを返す) + let authenticated = false; + const auth = req.headers.authorization || ''; + const token = auth.toLowerCase().startsWith('bearer ') ? auth.substring(7).trim() : (req.query.key || ''); + if (token) { + if (KEY_MAP[token]) { + authenticated = true; + } else { + try { jwt.verify(token, JWT_SECRET); authenticated = true; } catch (_) {} + } + } + + if (!authenticated) return res.json(base); + const mem = os.freemem(), total = os.totalmem(); let disk = null; try { @@ -618,18 +663,13 @@ function buildRouter() { disk = { total_gb: Math.round(parseInt(p[1])/1e9*10)/10, used_gb: Math.round(parseInt(p[2])/1e9*10)/10, use_pct: Math.round(parseInt(p[2])/parseInt(p[1])*100) }; } catch(_) {} res.json({ - status: 'ok', + ...base, gemini: !!genAI, - users: Object.values(KEY_MAP).length, - hostname: os.hostname(), uptime_s: Math.floor(os.uptime()), load_avg: os.loadavg().map(l => Math.round(l * 100) / 100), mem_used_mb: Math.round((total - mem) / 1024 / 1024), mem_total_mb: Math.round(total / 1024 / 1024), disk, - platform: os.platform(), - node_version: process.version, - timestamp: new Date().toISOString(), }); }); @@ -791,6 +831,8 @@ function buildRouter() { // GET /api/auth/oauth/google — redirect to Google r.get('/auth/oauth/google', (req, res) => { + const state = crypto.randomBytes(16).toString('hex'); + webauthnChallenges.set(`oauth:${state}`, { expiresAt: Date.now() + 10 * 60 * 1000 }); const params = new URLSearchParams({ client_id: GOOGLE_CLIENT_ID, redirect_uri: `${process.env.API_PUBLIC_URL || 'https://api.soar-enrich.com'}/brain/api/auth/oauth/google/callback`, @@ -798,14 +840,19 @@ function buildRouter() { scope: 'openid email profile', access_type: 'offline', prompt: 'select_account', + state, }); res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`); }); // GET /api/auth/oauth/google/callback r.get('/auth/oauth/google/callback', async (req, res) => { - const { code } = req.query; + const { code, state } = req.query; if (!code) return res.redirect(`${OAUTH_BASE_URL}/login?error=no_code`); + if (!state || !webauthnChallenges.has(`oauth:${state}`)) { + return res.redirect(`${OAUTH_BASE_URL}/login?error=invalid_state`); + } + webauthnChallenges.delete(`oauth:${state}`); try { // Exchange code for tokens const tokenRes = await fetch('https://oauth2.googleapis.com/token', { @@ -832,16 +879,22 @@ function buildRouter() { // Find or create user const existing = await pool.query( - `SELECT id FROM users WHERE email = $1`, [email] + `SELECT user_id FROM users WHERE email = $1`, [email] ); let userId; if (existing.rows.length > 0) { - userId = existing.rows[0].id; - } else { - const newUser = await pool.query( - `INSERT INTO users (email, created_at) VALUES ($1, NOW()) RETURNING id`, [email] + userId = existing.rows[0].user_id; + await pool.query( + `UPDATE users SET email_verified = true WHERE user_id = $1`, [userId] + ); + } else { + const baseId = email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30); + userId = `${baseId}_${Date.now().toString(36)}`; + await pool.query( + `INSERT INTO users (user_id, name, email, email_verified) VALUES ($1, $2, $3, true) + ON CONFLICT (user_id) DO NOTHING`, + [userId, baseId, email] ); - userId = newUser.rows[0].id; } const token = await createSessionJWT(userId); @@ -856,18 +909,25 @@ function buildRouter() { // GET /api/auth/oauth/github — redirect to GitHub r.get('/auth/oauth/github', (req, res) => { + const state = crypto.randomBytes(16).toString('hex'); + webauthnChallenges.set(`oauth:${state}`, { expiresAt: Date.now() + 10 * 60 * 1000 }); const params = new URLSearchParams({ client_id: GITHUB_CLIENT_ID, redirect_uri: `${process.env.API_PUBLIC_URL || 'https://api.soar-enrich.com'}/brain/api/auth/oauth/github/callback`, scope: 'user:email', + state, }); res.redirect(`https://github.com/login/oauth/authorize?${params}`); }); // GET /api/auth/oauth/github/callback r.get('/auth/oauth/github/callback', async (req, res) => { - const { code } = req.query; + const { code, state } = req.query; if (!code) return res.redirect(`${OAUTH_BASE_URL}/login?error=no_code`); + if (!state || !webauthnChallenges.has(`oauth:${state}`)) { + return res.redirect(`${OAUTH_BASE_URL}/login?error=invalid_state`); + } + webauthnChallenges.delete(`oauth:${state}`); try { // Exchange code for token const tokenRes = await fetch('https://github.com/login/oauth/access_token', { @@ -894,16 +954,22 @@ function buildRouter() { // Find or create user const existing = await pool.query( - `SELECT id FROM users WHERE email = $1`, [email] + `SELECT user_id FROM users WHERE email = $1`, [email] ); let userId; if (existing.rows.length > 0) { - userId = existing.rows[0].id; - } else { - const newUser = await pool.query( - `INSERT INTO users (email, created_at) VALUES ($1, NOW()) RETURNING id`, [email] + userId = existing.rows[0].user_id; + await pool.query( + `UPDATE users SET email_verified = true WHERE user_id = $1`, [userId] + ); + } else { + const baseId = email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30); + userId = `${baseId}_${Date.now().toString(36)}`; + await pool.query( + `INSERT INTO users (user_id, name, email, email_verified) VALUES ($1, $2, $3, true) + ON CONFLICT (user_id) DO NOTHING`, + [userId, baseId, email] ); - userId = newUser.rows[0].id; } const token = await createSessionJWT(userId); @@ -1053,6 +1119,11 @@ function buildRouter() { r.post('/auth/passkey/login/begin', async (req, res) => { if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' }); const { email } = req.body; + // レート制限: IP ごとに 10回/分 + const ip = req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown'; + if (!checkRateLimit('passkey_login', ip, 10, 60 * 1000)) { + return res.status(429).json({ error: '試行回数が多すぎます。しばらくしてからお試しください' }); + } const challengeKey = email ? `login:${email.toLowerCase().trim()}` : `login:anon:${crypto.randomBytes(8).toString('hex')}`; try { let allowCredentials = []; @@ -1271,14 +1342,16 @@ function buildRouter() { res.json({ ok: true, article, aiStatus: 'pending' }); - // バックグラウンドでAI処理 - analyzeWithGemini(clientTitle || meta.title, fullText || meta.desc, url).then(async (ai) => { - await pool.query(` - UPDATE articles SET summary=$1, topics=$2, reading_time=$3 - WHERE user_id=$4 AND url=$5 - `, [ai.summary, ai.topics, ai.readingTime, req.userId, url]); - console.log(`[Brain API] ✓ AI analysis completed for ${url}`); - }).catch(e => console.error('[Background AI Error]:', e)); + // バックグラウンドでAI処理(ユーザーごとに 50記事/時間 まで) + if (checkRateLimit('gemini_analyze', req.userId, 50, 60 * 60 * 1000)) { + analyzeWithGemini(clientTitle || meta.title, fullText || meta.desc, url).then(async (ai) => { + await pool.query(` + UPDATE articles SET summary=$1, topics=$2, reading_time=$3 + WHERE user_id=$4 AND url=$5 + `, [ai.summary, ai.topics, ai.readingTime, req.userId, url]); + console.log(`[Brain API] ✓ AI analysis completed for ${url}`); + }).catch(e => console.error('[Background AI Error]:', e)); + } } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'すでに保存済みです' }); @@ -1341,13 +1414,15 @@ function buildRouter() { SET title=EXCLUDED.title, full_text=EXCLUDED.full_text, source=EXCLUDED.source, summary='⏳ 再分析中...' `, [req.userId, url, meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]); - // バックグラウンドAI - analyzeWithGemini(meta.title, fullText, url).then(async (ai) => { - await pool.query(` - UPDATE articles SET summary=$1, topics=$2, reading_time=$3 - WHERE user_id=$4 AND url=$5 - `, [ai.summary, ai.topics, ai.readingTime, req.userId, url]); - }).catch(e => console.error('[Background AI Error]:', e)); + // バックグラウンドAI(ユーザーごとに 50記事/時間 まで) + if (checkRateLimit('gemini_analyze', req.userId, 50, 60 * 60 * 1000)) { + analyzeWithGemini(meta.title, fullText, url).then(async (ai) => { + await pool.query(` + UPDATE articles SET summary=$1, topics=$2, reading_time=$3 + WHERE user_id=$4 AND url=$5 + `, [ai.summary, ai.topics, ai.readingTime, req.userId, url]); + }).catch(e => console.error('[Background AI Error]:', e)); + } // HTMLレスポンス(自動で閉じる) res.send(` @@ -1523,6 +1598,10 @@ function buildRouter() { // POST /journal/suggest-tags — Gemini でタグ候補を提案 r.post('/journal/suggest-tags', authMiddleware, async (req, res) => { if (!genAI) return res.status(503).json({ error: 'Gemini not configured' }); + // レート制限: ユーザーごとに 10回/時間 + if (!checkRateLimit('gemini_suggest_tags', req.userId, 10, 60 * 60 * 1000)) { + return res.status(429).json({ error: 'AI提案の利用回数が上限に達しました。1時間後に再試行してください' }); + } const { title = '', body = '' } = req.body || {}; if (!title && !body) return res.status(400).json({ error: 'title or body required' }); try {