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:
parent
e4ec2c1226
commit
d6f7b487d0
|
|
@ -213,10 +213,16 @@ app.get('/api/vercel-deploys', async (req, res) => {
|
|||
|
||||
// ── VPS health プロキシ (/api/vps-health) ──────────────────────
|
||||
// ブラウザから直接叩くと自己署名証明書環境でCORSエラーになるためサーバー経由でプロキシ
|
||||
// VPS_API_KEY を .env に設定すると詳細情報(メモリ・ディスク等)を取得できる
|
||||
app.get('/api/vps-health', async (req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
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', {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(6000),
|
||||
});
|
||||
const data = await r.json();
|
||||
|
|
|
|||
123
server.js
123
server.js
|
|
@ -43,6 +43,33 @@ setInterval(() => {
|
|||
}
|
||||
}, 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) {
|
||||
return String(str)
|
||||
|
|
@ -608,8 +635,26 @@ function buildRouter() {
|
|||
const r = express.Router();
|
||||
|
||||
// ヘルスチェック(Station コックピット向けに拡張)
|
||||
// 認証なし: 最小限レスポンス(外部監視ツール向け)
|
||||
// 認証あり(API Key / JWT): 詳細システム情報を追加
|
||||
r.get('/health', (req, res) => {
|
||||
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();
|
||||
let disk = null;
|
||||
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) };
|
||||
} catch(_) {}
|
||||
res.json({
|
||||
status: 'ok',
|
||||
...base,
|
||||
gemini: !!genAI,
|
||||
users: Object.values(KEY_MAP).length,
|
||||
hostname: os.hostname(),
|
||||
uptime_s: Math.floor(os.uptime()),
|
||||
load_avg: os.loadavg().map(l => Math.round(l * 100) / 100),
|
||||
mem_used_mb: Math.round((total - mem) / 1024 / 1024),
|
||||
mem_total_mb: Math.round(total / 1024 / 1024),
|
||||
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
|
||||
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({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
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',
|
||||
access_type: 'offline',
|
||||
prompt: 'select_account',
|
||||
state,
|
||||
});
|
||||
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
|
||||
});
|
||||
|
||||
// GET /api/auth/oauth/google/callback
|
||||
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 (!state || !webauthnChallenges.has(`oauth:${state}`)) {
|
||||
return res.redirect(`${OAUTH_BASE_URL}/login?error=invalid_state`);
|
||||
}
|
||||
webauthnChallenges.delete(`oauth:${state}`);
|
||||
try {
|
||||
// Exchange code for tokens
|
||||
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||
|
|
@ -832,16 +879,22 @@ function buildRouter() {
|
|||
|
||||
// Find or create user
|
||||
const existing = await pool.query(
|
||||
`SELECT id FROM users WHERE email = $1`, [email]
|
||||
`SELECT user_id FROM users WHERE email = $1`, [email]
|
||||
);
|
||||
let userId;
|
||||
if (existing.rows.length > 0) {
|
||||
userId = existing.rows[0].id;
|
||||
} else {
|
||||
const newUser = await pool.query(
|
||||
`INSERT INTO users (email, created_at) VALUES ($1, NOW()) RETURNING id`, [email]
|
||||
userId = existing.rows[0].user_id;
|
||||
await pool.query(
|
||||
`UPDATE users SET email_verified = true WHERE user_id = $1`, [userId]
|
||||
);
|
||||
} 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);
|
||||
|
|
@ -856,18 +909,25 @@ function buildRouter() {
|
|||
|
||||
// GET /api/auth/oauth/github — redirect to GitHub
|
||||
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({
|
||||
client_id: GITHUB_CLIENT_ID,
|
||||
redirect_uri: `${process.env.API_PUBLIC_URL || 'https://api.soar-enrich.com'}/brain/api/auth/oauth/github/callback`,
|
||||
scope: 'user:email',
|
||||
state,
|
||||
});
|
||||
res.redirect(`https://github.com/login/oauth/authorize?${params}`);
|
||||
});
|
||||
|
||||
// GET /api/auth/oauth/github/callback
|
||||
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 (!state || !webauthnChallenges.has(`oauth:${state}`)) {
|
||||
return res.redirect(`${OAUTH_BASE_URL}/login?error=invalid_state`);
|
||||
}
|
||||
webauthnChallenges.delete(`oauth:${state}`);
|
||||
try {
|
||||
// Exchange code for token
|
||||
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
|
||||
|
|
@ -894,16 +954,22 @@ function buildRouter() {
|
|||
|
||||
// Find or create user
|
||||
const existing = await pool.query(
|
||||
`SELECT id FROM users WHERE email = $1`, [email]
|
||||
`SELECT user_id FROM users WHERE email = $1`, [email]
|
||||
);
|
||||
let userId;
|
||||
if (existing.rows.length > 0) {
|
||||
userId = existing.rows[0].id;
|
||||
} else {
|
||||
const newUser = await pool.query(
|
||||
`INSERT INTO users (email, created_at) VALUES ($1, NOW()) RETURNING id`, [email]
|
||||
userId = existing.rows[0].user_id;
|
||||
await pool.query(
|
||||
`UPDATE users SET email_verified = true WHERE user_id = $1`, [userId]
|
||||
);
|
||||
} 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);
|
||||
|
|
@ -1053,6 +1119,11 @@ function buildRouter() {
|
|||
r.post('/auth/passkey/login/begin', async (req, res) => {
|
||||
if (!webauthn) return res.status(503).json({ error: 'WebAuthn not available' });
|
||||
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')}`;
|
||||
try {
|
||||
let allowCredentials = [];
|
||||
|
|
@ -1271,7 +1342,8 @@ function buildRouter() {
|
|||
|
||||
res.json({ ok: true, article, aiStatus: 'pending' });
|
||||
|
||||
// バックグラウンドでAI処理
|
||||
// バックグラウンドでAI処理(ユーザーごとに 50記事/時間 まで)
|
||||
if (checkRateLimit('gemini_analyze', req.userId, 50, 60 * 60 * 1000)) {
|
||||
analyzeWithGemini(clientTitle || meta.title, fullText || meta.desc, url).then(async (ai) => {
|
||||
await pool.query(`
|
||||
UPDATE articles SET summary=$1, topics=$2, reading_time=$3
|
||||
|
|
@ -1279,6 +1351,7 @@ function buildRouter() {
|
|||
`, [ai.summary, ai.topics, ai.readingTime, req.userId, url]);
|
||||
console.log(`[Brain API] ✓ AI analysis completed for ${url}`);
|
||||
}).catch(e => console.error('[Background AI Error]:', e));
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
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='⏳ 再分析中...'
|
||||
`, [req.userId, url, meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]);
|
||||
|
||||
// バックグラウンドAI
|
||||
// バックグラウンドAI(ユーザーごとに 50記事/時間 まで)
|
||||
if (checkRateLimit('gemini_analyze', req.userId, 50, 60 * 60 * 1000)) {
|
||||
analyzeWithGemini(meta.title, fullText, url).then(async (ai) => {
|
||||
await pool.query(`
|
||||
UPDATE articles SET summary=$1, topics=$2, reading_time=$3
|
||||
WHERE user_id=$4 AND url=$5
|
||||
`, [ai.summary, ai.topics, ai.readingTime, req.userId, url]);
|
||||
}).catch(e => console.error('[Background AI Error]:', e));
|
||||
}
|
||||
|
||||
// HTMLレスポンス(自動で閉じる)
|
||||
res.send(`
|
||||
|
|
@ -1523,6 +1598,10 @@ function buildRouter() {
|
|||
// POST /journal/suggest-tags — Gemini でタグ候補を提案
|
||||
r.post('/journal/suggest-tags', authMiddleware, async (req, res) => {
|
||||
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 || {};
|
||||
if (!title && !body) return res.status(400).json({ error: 'title or body required' });
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in New Issue