diff --git a/routes/ponshu.js b/routes/ponshu.js index 33bf9932..5513b2bf 100644 --- a/routes/ponshu.js +++ b/routes/ponshu.js @@ -8,7 +8,7 @@ const express = require('express'); * POST /ponshu/license/validate (認証不要 — モバイルアプリから直接呼ぶ) * POST /ponshu/admin/license/revoke (認証必須) */ -module.exports = function createPonshuRouter(pool, authMiddleware) { +module.exports = function createPonshuRouter(pool, authMiddleware, checkRateLimit) { const router = express.Router(); const APP_SUPPORT_EMAIL = process.env.APP_SUPPORT_EMAIL || 'support@posimai.soar-enrich.com'; @@ -20,6 +20,11 @@ module.exports = function createPonshuRouter(pool, authMiddleware) { return res.status(400).json({ valid: false, error: 'Missing parameters' }); } + // IP ごとに 10回/分 の制限(ブルートフォース防止) + if (checkRateLimit && !checkRateLimit('ponshu_validate', req.ip || 'unknown', 10, 60 * 1000)) { + return res.status(429).json({ valid: false, error: 'リクエストが多すぎます。しばらく待ってから再試行してください' }); + } + try { const result = await pool.query( `SELECT license_key, plan, status, device_id FROM ponshu_licenses WHERE license_key = $1`, diff --git a/server.js b/server.js index e584d975..98a67107 100644 --- a/server.js +++ b/server.js @@ -1762,24 +1762,17 @@ function buildRouter() { // ── Journal API ────────────────────────── // GET /journal/posts/public — 認証不要・published=true のみ(posimai-site 用) - // ?user=maita でユーザー指定可能(将来の独立サイト対応) + // SITE_PUBLIC_USER 環境変数で許可ユーザーを固定(任意ユーザー列挙を防止) r.get('/journal/posts/public', async (req, res) => { try { const limit = Math.min(parseInt(req.query.limit || '50') || 50, 100); - const userId = req.query.user || null; - const { rows } = userId - ? await pool.query( - `SELECT id, title, body, tags, created_at, updated_at - FROM journal_posts WHERE published=TRUE AND user_id=$1 - ORDER BY updated_at DESC LIMIT $2`, - [userId, limit] - ) - : await pool.query( - `SELECT id, title, body, tags, created_at, updated_at - FROM journal_posts WHERE published=TRUE - ORDER BY updated_at DESC LIMIT $1`, - [limit] - ); + const allowedUser = process.env.SITE_PUBLIC_USER || 'maita'; + const { rows } = await pool.query( + `SELECT id, title, body, tags, created_at, updated_at + FROM journal_posts WHERE published=TRUE AND user_id=$1 + ORDER BY updated_at DESC LIMIT $2`, + [allowedUser, limit] + ); res.json({ posts: rows }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); @@ -1835,16 +1828,27 @@ function buildRouter() { // ── Site Config API ─────────────────────── // GET /site/config/public — 認証不要・posimai-site 用 - // ?user=maita でユーザー指定可能(将来の独立サイト対応) + // SITE_PUBLIC_USER 環境変数で許可ユーザーを固定(任意ユーザー列挙を防止) + // SITE_CONFIG_PUBLIC_KEYS 環境変数でカンマ区切りの公開キーを指定可(未設定時はデフォルト一覧) + const DEFAULT_SITE_CONFIG_PUBLIC_KEYS = new Set([ + 'atlas_data', 'diary_content', 'site_title', 'site_description', + 'profile_name', 'profile_bio', 'avatar_url', 'accent_color', + 'header_image', 'social_links', 'theme', + ]); r.get('/site/config/public', async (req, res) => { try { - const userId = req.query.user || 'maita'; + const allowedUser = process.env.SITE_PUBLIC_USER || 'maita'; + const allowedKeys = process.env.SITE_CONFIG_PUBLIC_KEYS + ? new Set(process.env.SITE_CONFIG_PUBLIC_KEYS.split(',').map(k => k.trim())) + : DEFAULT_SITE_CONFIG_PUBLIC_KEYS; const { rows } = await pool.query( 'SELECT key, value FROM site_config WHERE user_id=$1', - [userId] + [allowedUser] ); const config = {}; - rows.forEach(row => { config[row.key] = row.value; }); + rows.forEach(row => { + if (allowedKeys.has(row.key)) config[row.key] = row.value; + }); res.json({ config }); } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } }); @@ -2535,6 +2539,9 @@ ${excerpt} r.post('/together/groups', async (req, res) => { const { name, username } = req.body || {}; if (!name || !username) return res.status(400).json({ error: 'name と username は必須です' }); + if (!checkRateLimit('together_create', req.ip || 'unknown', 5, 60 * 60 * 1000)) { + return res.status(429).json({ error: 'グループ作成の上限に達しました。1時間後に再試行してください' }); + } try { let invite_code, attempt = 0; while (attempt < 5) { @@ -2562,6 +2569,9 @@ ${excerpt} r.post('/together/join', async (req, res) => { const { invite_code, username } = req.body || {}; if (!invite_code || !username) return res.status(400).json({ error: 'invite_code と username は必須です' }); + if (!checkRateLimit('together_join', req.ip || 'unknown', 10, 60 * 60 * 1000)) { + return res.status(429).json({ error: '参加試行回数が上限に達しました。1時間後に再試行してください' }); + } try { const group = await pool.query( 'SELECT * FROM together_groups WHERE invite_code=$1', @@ -2636,7 +2646,14 @@ ${excerpt} res.json({ ok: true, share }); // URL がある場合のみ非同期アーカイブ(ユーザーを待たせない) - if (url) archiveShare(share.id, url); + // shared_by ごとに 20回/時間 の Gemini 呼び出し制限 + if (url) { + if (checkRateLimit('together_archive', shared_by, 20, 60 * 60 * 1000)) { + archiveShare(share.id, url); + } else { + pool.query(`UPDATE together_shares SET archive_status='skipped' WHERE id=$1`, [share.id]).catch(() => {}); + } + } } catch (e) { console.error('[together/share]', e.message); res.status(500).json({ error: 'Internal server error' }); @@ -3102,7 +3119,7 @@ app.post('/api/stripe/webhook', ); // ── Ponshu Room ライセンス (routes/ponshu.js) ───────────────────────── -const ponshuRouter = require('./routes/ponshu')(pool, authMiddleware); +const ponshuRouter = require('./routes/ponshu')(pool, authMiddleware, checkRateLimit); app.use('/brain/api', ponshuRouter); app.use('/api', ponshuRouter);