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:
parent
10071901e1
commit
548c4fcca2
|
|
@ -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`,
|
||||||
|
|
|
||||||
51
server.js
51
server.js
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue