fix: security hardening for commercial release

- 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 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-04-04 23:04:20 +09:00
parent e4ec2c1226
commit d6f7b487d0
2 changed files with 120 additions and 35 deletions

View File

@ -213,10 +213,16 @@ app.get('/api/vercel-deploys', async (req, res) => {
// ── VPS health プロキシ (/api/vps-health) ────────────────────── // ── VPS health プロキシ (/api/vps-health) ──────────────────────
// ブラウザから直接叩くと自己署名証明書環境でCORSエラーになるためサーバー経由でプロキシ // ブラウザから直接叩くと自己署名証明書環境でCORSエラーになるためサーバー経由でプロキシ
// VPS_API_KEY を .env に設定すると詳細情報(メモリ・ディスク等)を取得できる
app.get('/api/vps-health', async (req, res) => { app.get('/api/vps-health', async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Origin', '*');
try { 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', { const r = await fetch('https://api.soar-enrich.com/api/health', {
headers,
signal: AbortSignal.timeout(6000), signal: AbortSignal.timeout(6000),
}); });
const data = await r.json(); const data = await r.json();

149
server.js
View File

@ -43,6 +43,33 @@ setInterval(() => {
} }
}, 10 * 60 * 1000); }, 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) { function escapeHtml(str) {
return String(str) return String(str)
@ -608,8 +635,26 @@ function buildRouter() {
const r = express.Router(); const r = express.Router();
// ヘルスチェックStation コックピット向けに拡張) // ヘルスチェックStation コックピット向けに拡張)
// 認証なし: 最小限レスポンス(外部監視ツール向け)
// 認証ありAPI Key / JWT: 詳細システム情報を追加
r.get('/health', (req, res) => { r.get('/health', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*'); 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(); const mem = os.freemem(), total = os.totalmem();
let disk = null; let disk = null;
try { 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) }; 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(_) {} } catch(_) {}
res.json({ res.json({
status: 'ok', ...base,
gemini: !!genAI, gemini: !!genAI,
users: Object.values(KEY_MAP).length,
hostname: os.hostname(),
uptime_s: Math.floor(os.uptime()), uptime_s: Math.floor(os.uptime()),
load_avg: os.loadavg().map(l => Math.round(l * 100) / 100), load_avg: os.loadavg().map(l => Math.round(l * 100) / 100),
mem_used_mb: Math.round((total - mem) / 1024 / 1024), mem_used_mb: Math.round((total - mem) / 1024 / 1024),
mem_total_mb: Math.round(total / 1024 / 1024), mem_total_mb: Math.round(total / 1024 / 1024),
disk, 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 // GET /api/auth/oauth/google — redirect to Google
r.get('/auth/oauth/google', (req, res) => { 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({ const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID, client_id: GOOGLE_CLIENT_ID,
redirect_uri: `${process.env.API_PUBLIC_URL || 'https://api.soar-enrich.com'}/brain/api/auth/oauth/google/callback`, 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', scope: 'openid email profile',
access_type: 'offline', access_type: 'offline',
prompt: 'select_account', prompt: 'select_account',
state,
}); });
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`); res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
}); });
// GET /api/auth/oauth/google/callback // GET /api/auth/oauth/google/callback
r.get('/auth/oauth/google/callback', async (req, res) => { 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 (!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 { try {
// Exchange code for tokens // Exchange code for tokens
const tokenRes = await fetch('https://oauth2.googleapis.com/token', { const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
@ -832,16 +879,22 @@ function buildRouter() {
// Find or create user // Find or create user
const existing = await pool.query( const existing = await pool.query(
`SELECT id FROM users WHERE email = $1`, [email] `SELECT user_id FROM users WHERE email = $1`, [email]
); );
let userId; let userId;
if (existing.rows.length > 0) { if (existing.rows.length > 0) {
userId = existing.rows[0].id; userId = existing.rows[0].user_id;
} else { await pool.query(
const newUser = await pool.query( `UPDATE users SET email_verified = true WHERE user_id = $1`, [userId]
`INSERT INTO users (email, created_at) VALUES ($1, NOW()) RETURNING id`, [email] );
} 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); const token = await createSessionJWT(userId);
@ -856,18 +909,25 @@ function buildRouter() {
// GET /api/auth/oauth/github — redirect to GitHub // GET /api/auth/oauth/github — redirect to GitHub
r.get('/auth/oauth/github', (req, res) => { 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({ const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID, client_id: GITHUB_CLIENT_ID,
redirect_uri: `${process.env.API_PUBLIC_URL || 'https://api.soar-enrich.com'}/brain/api/auth/oauth/github/callback`, redirect_uri: `${process.env.API_PUBLIC_URL || 'https://api.soar-enrich.com'}/brain/api/auth/oauth/github/callback`,
scope: 'user:email', scope: 'user:email',
state,
}); });
res.redirect(`https://github.com/login/oauth/authorize?${params}`); res.redirect(`https://github.com/login/oauth/authorize?${params}`);
}); });
// GET /api/auth/oauth/github/callback // GET /api/auth/oauth/github/callback
r.get('/auth/oauth/github/callback', async (req, res) => { 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 (!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 { try {
// Exchange code for token // Exchange code for token
const tokenRes = await fetch('https://github.com/login/oauth/access_token', { const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
@ -894,16 +954,22 @@ function buildRouter() {
// Find or create user // Find or create user
const existing = await pool.query( const existing = await pool.query(
`SELECT id FROM users WHERE email = $1`, [email] `SELECT user_id FROM users WHERE email = $1`, [email]
); );
let userId; let userId;
if (existing.rows.length > 0) { if (existing.rows.length > 0) {
userId = existing.rows[0].id; userId = existing.rows[0].user_id;
} else { await pool.query(
const newUser = await pool.query( `UPDATE users SET email_verified = true WHERE user_id = $1`, [userId]
`INSERT INTO users (email, created_at) VALUES ($1, NOW()) RETURNING id`, [email] );
} 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); const token = await createSessionJWT(userId);
@ -1053,6 +1119,11 @@ function buildRouter() {
r.post('/auth/passkey/login/begin', async (req, res) => { r.post('/auth/passkey/login/begin', async (req, res) => {
if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' }); if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' });
const { email } = req.body; 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')}`; const challengeKey = email ? `login:${email.toLowerCase().trim()}` : `login:anon:${crypto.randomBytes(8).toString('hex')}`;
try { try {
let allowCredentials = []; let allowCredentials = [];
@ -1271,14 +1342,16 @@ function buildRouter() {
res.json({ ok: true, article, aiStatus: 'pending' }); res.json({ ok: true, article, aiStatus: 'pending' });
// バックグラウンドでAI処理 // バックグラウンドでAI処理ユーザーごとに 50記事/時間 まで)
analyzeWithGemini(clientTitle || meta.title, fullText || meta.desc, url).then(async (ai) => { if (checkRateLimit('gemini_analyze', req.userId, 50, 60 * 60 * 1000)) {
await pool.query(` analyzeWithGemini(clientTitle || meta.title, fullText || meta.desc, url).then(async (ai) => {
UPDATE articles SET summary=$1, topics=$2, reading_time=$3 await pool.query(`
WHERE user_id=$4 AND url=$5 UPDATE articles SET summary=$1, topics=$2, reading_time=$3
`, [ai.summary, ai.topics, ai.readingTime, req.userId, url]); WHERE user_id=$4 AND url=$5
console.log(`[Brain API] ✓ AI analysis completed for ${url}`); `, [ai.summary, ai.topics, ai.readingTime, req.userId, url]);
}).catch(e => console.error('[Background AI Error]:', e)); console.log(`[Brain API] ✓ AI analysis completed for ${url}`);
}).catch(e => console.error('[Background AI Error]:', e));
}
} catch (e) { } catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'すでに保存済みです' }); 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='⏳ 再分析中...' 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]); `, [req.userId, url, meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]);
// バックグラウンドAI // バックグラウンドAIユーザーごとに 50記事/時間 まで)
analyzeWithGemini(meta.title, fullText, url).then(async (ai) => { if (checkRateLimit('gemini_analyze', req.userId, 50, 60 * 60 * 1000)) {
await pool.query(` analyzeWithGemini(meta.title, fullText, url).then(async (ai) => {
UPDATE articles SET summary=$1, topics=$2, reading_time=$3 await pool.query(`
WHERE user_id=$4 AND url=$5 UPDATE articles SET summary=$1, topics=$2, reading_time=$3
`, [ai.summary, ai.topics, ai.readingTime, req.userId, url]); WHERE user_id=$4 AND url=$5
}).catch(e => console.error('[Background AI Error]:', e)); `, [ai.summary, ai.topics, ai.readingTime, req.userId, url]);
}).catch(e => console.error('[Background AI Error]:', e));
}
// HTMLレスポンス自動で閉じる // HTMLレスポンス自動で閉じる
res.send(` res.send(`
@ -1523,6 +1598,10 @@ function buildRouter() {
// POST /journal/suggest-tags — Gemini でタグ候補を提案 // POST /journal/suggest-tags — Gemini でタグ候補を提案
r.post('/journal/suggest-tags', authMiddleware, async (req, res) => { r.post('/journal/suggest-tags', authMiddleware, async (req, res) => {
if (!genAI) return res.status(503).json({ error: 'Gemini not configured' }); 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 || {}; const { title = '', body = '' } = req.body || {};
if (!title && !body) return res.status(400).json({ error: 'title or body required' }); if (!title && !body) return res.status(400).json({ error: 'title or body required' });
try { try {