fix: SSRF blocklist + レスポンスサイズ制限 + DB pool max 15 + pool.on(error)

This commit is contained in:
posimai 2026-04-09 23:45:55 +09:00
parent 1336b20c90
commit 5a3a510331
1 changed files with 31 additions and 2 deletions

View File

@ -177,7 +177,13 @@ const pool = new Pool({
user: process.env.DB_USER || 'gitea', user: process.env.DB_USER || 'gitea',
password: process.env.DB_PASSWORD || '', password: process.env.DB_PASSWORD || '',
database: 'posimai_brain', database: 'posimai_brain',
max: 5 max: 15,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
// プールレベルの接続エラーをキャッチ(未処理のままにしない)
pool.on('error', (err) => {
console.error('[DB] Unexpected pool error:', err.message);
}); });
// ── Gemini ──────────────────────────────── // ── Gemini ────────────────────────────────
@ -282,15 +288,37 @@ function normalizeCharset(raw) {
return 'utf-8'; return 'utf-8';
} }
// ── SSRF ガードfetchMeta / fetchFullTextViaJina 共用)──────────────
// RFC 1918 プライベート帯域・ループバック・クラウドメタデータ IP をブロック
const SSRF_BLOCKED = /^(127\.|localhost$|::1$|0\.0\.0\.0$|169\.254\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|100\.100\.100\.100|metadata\.google\.internal)/i;
function isSsrfSafe(rawUrl) {
let parsed;
try { parsed = new URL(rawUrl); } catch { return false; }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
if (SSRF_BLOCKED.test(parsed.hostname)) return false;
return true;
}
// ── OGP フェッチ ─────────────────────────── // ── OGP フェッチ ───────────────────────────
const FETCH_META_MAX_BYTES = 2 * 1024 * 1024; // 2 MB 上限
async function fetchMeta(url) { async function fetchMeta(url) {
if (!isSsrfSafe(url)) {
return { title: url.slice(0, 300), desc: '', ogImage: '', favicon: '' };
}
try { try {
const res = await fetch(url, { const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; PosimaiBot/1.0)' }, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; PosimaiBot/1.0)' },
signal: AbortSignal.timeout(6000) signal: AbortSignal.timeout(6000)
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buffer = await res.arrayBuffer();
// レスポンスサイズを 2MB に制限OGP取得にそれ以上は不要
const contentLength = parseInt(res.headers.get('content-length') || '0', 10);
if (contentLength > FETCH_META_MAX_BYTES) throw new Error('Response too large');
const rawBuffer = await res.arrayBuffer();
const buffer = rawBuffer.byteLength > FETCH_META_MAX_BYTES
? rawBuffer.slice(0, FETCH_META_MAX_BYTES)
: rawBuffer;
// 文字コード判定: 1) Content-Typeヘッダー優先 2) HTMLメタタグ確認 // 文字コード判定: 1) Content-Typeヘッダー優先 2) HTMLメタタグ確認
// iso-8859-1はバイト値0-255をロスレスでデコードするためcharset検出に最適 // iso-8859-1はバイト値0-255をロスレスでデコードするためcharset検出に最適
@ -341,6 +369,7 @@ async function fetchMeta(url) {
// ── Jina Reader API フェッチ(新規追加)─── // ── Jina Reader API フェッチ(新規追加)───
async function fetchFullTextViaJina(url) { async function fetchFullTextViaJina(url) {
if (!isSsrfSafe(url)) return null;
try { try {
console.log(`[Brain API] Fetching full text via Jina Reader for: ${url}`); console.log(`[Brain API] Fetching full text via Jina Reader for: ${url}`);