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/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`,

View File

@ -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);