security: 認証・レート制限の5箇所を修正

- /journal/posts/public: 任意ユーザー列挙を廃止、SITE_PUBLIC_USER 固定
- /site/config/public: IDOR 修正 + 公開キーをホワイトリスト制限
- POST /together/groups: IP 単位 5回/時間 のレート制限を追加
- POST /together/join: IP 単位 10回/時間 のレート制限を追加
- POST /together/share: Gemini archive を shared_by 単位 20回/時間 に制限
- POST /ponshu/license/validate: IP 単位 10回/分 のブルートフォース防止

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-04-20 13:30:10 +09:00
parent 10071901e1
commit 548c4fcca2
2 changed files with 44 additions and 22 deletions

View File

@ -8,7 +8,7 @@ const express = require('express');
* POST /ponshu/license/validate (認証不要 モバイルアプリから直接呼ぶ) * POST /ponshu/license/validate (認証不要 モバイルアプリから直接呼ぶ)
* POST /ponshu/admin/license/revoke (認証必須) * POST /ponshu/admin/license/revoke (認証必須)
*/ */
module.exports = function createPonshuRouter(pool, authMiddleware) { module.exports = function createPonshuRouter(pool, authMiddleware, checkRateLimit) {
const router = express.Router(); const router = express.Router();
const APP_SUPPORT_EMAIL = process.env.APP_SUPPORT_EMAIL || 'support@posimai.soar-enrich.com'; 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' }); 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 { try {
const result = await pool.query( const result = await pool.query(
`SELECT license_key, plan, status, device_id FROM ponshu_licenses WHERE license_key = $1`, `SELECT license_key, plan, status, device_id FROM ponshu_licenses WHERE license_key = $1`,

View File

@ -1762,23 +1762,16 @@ function buildRouter() {
// ── Journal API ────────────────────────── // ── Journal API ──────────────────────────
// GET /journal/posts/public — 認証不要・published=true のみposimai-site 用) // GET /journal/posts/public — 認証不要・published=true のみposimai-site 用)
// ?user=maita でユーザー指定可能(将来の独立サイト対応 // SITE_PUBLIC_USER 環境変数で許可ユーザーを固定(任意ユーザー列挙を防止
r.get('/journal/posts/public', async (req, res) => { r.get('/journal/posts/public', async (req, res) => {
try { try {
const limit = Math.min(parseInt(req.query.limit || '50') || 50, 100); const limit = Math.min(parseInt(req.query.limit || '50') || 50, 100);
const userId = req.query.user || null; const allowedUser = process.env.SITE_PUBLIC_USER || 'maita';
const { rows } = userId const { rows } = await pool.query(
? await pool.query(
`SELECT id, title, body, tags, created_at, updated_at `SELECT id, title, body, tags, created_at, updated_at
FROM journal_posts WHERE published=TRUE AND user_id=$1 FROM journal_posts WHERE published=TRUE AND user_id=$1
ORDER BY updated_at DESC LIMIT $2`, ORDER BY updated_at DESC LIMIT $2`,
[userId, limit] [allowedUser, 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]
); );
res.json({ posts: rows }); res.json({ posts: rows });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
@ -1835,16 +1828,27 @@ function buildRouter() {
// ── Site Config API ─────────────────────── // ── Site Config API ───────────────────────
// GET /site/config/public — 認証不要・posimai-site 用 // 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) => { r.get('/site/config/public', async (req, res) => {
try { 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( const { rows } = await pool.query(
'SELECT key, value FROM site_config WHERE user_id=$1', 'SELECT key, value FROM site_config WHERE user_id=$1',
[userId] [allowedUser]
); );
const config = {}; 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 }); res.json({ config });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
}); });
@ -2535,6 +2539,9 @@ ${excerpt}
r.post('/together/groups', async (req, res) => { r.post('/together/groups', async (req, res) => {
const { name, username } = req.body || {}; const { name, username } = req.body || {};
if (!name || !username) return res.status(400).json({ error: 'name と username は必須です' }); 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 { try {
let invite_code, attempt = 0; let invite_code, attempt = 0;
while (attempt < 5) { while (attempt < 5) {
@ -2562,6 +2569,9 @@ ${excerpt}
r.post('/together/join', async (req, res) => { r.post('/together/join', async (req, res) => {
const { invite_code, username } = req.body || {}; const { invite_code, username } = req.body || {};
if (!invite_code || !username) return res.status(400).json({ error: 'invite_code と username は必須です' }); 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 { try {
const group = await pool.query( const group = await pool.query(
'SELECT * FROM together_groups WHERE invite_code=$1', 'SELECT * FROM together_groups WHERE invite_code=$1',
@ -2636,7 +2646,14 @@ ${excerpt}
res.json({ ok: true, share }); res.json({ ok: true, share });
// URL がある場合のみ非同期アーカイブ(ユーザーを待たせない) // 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) { } catch (e) {
console.error('[together/share]', e.message); console.error('[together/share]', e.message);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
@ -3102,7 +3119,7 @@ app.post('/api/stripe/webhook',
); );
// ── Ponshu Room ライセンス (routes/ponshu.js) ───────────────────────── // ── 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('/brain/api', ponshuRouter);
app.use('/api', ponshuRouter); app.use('/api', ponshuRouter);