30 lines
2.7 KiB
JavaScript
30 lines
2.7 KiB
JavaScript
'use strict';
|
|||
|
|
// ============================================================================
|
||
|
|
// 요청 속도 제한 · 신뢰 프록시 기준 클라이언트 IP (v1.6.31, 요구 7·25)
|
||
|
|
//
|
||
|
|
// 1.5.x 까지는 X-Forwarded-For 첫 값을 그대로 믿어 공격자가 헤더를 위조하면 제한을 피할 수 있었다(검토 S12).
|
||
|
|
// clientIp(req): 연결 상대(req.socket.remoteAddress)가 설정 service.trustedProxies(기본 127.0.0.1, ::1 — 같은 서버의 nginx)에 들어 있을 때만
|
||
|
|
// X-Forwarded-For 의 "신뢰 프록시 바로 앞" 값을 쓴다. 그 밖은 연결 상대 IP.
|
||
|
|
// limited(key, max, windowMs): 슬라이딩 창. 메모리 보관(프로세스 재시작 때 비워짐 — 공격자가 재시작을 일으킬 수는 없다).
|
||
|
|
// lockout(key, fails, windowMs): 로그인 실패 누적 잠금(계정 기준) — 비밀번호 대입 방지.
|
||
|
|
// ============================================================================
|
||
|
|
const buckets = new Map(); // key → number[] (timestamps)
|
||
|
|
let lastSweep = Date.now();
|
||
|
|
function sweep() { const now = Date.now(); if (now - lastSweep < 60000) return; lastSweep = now; for (const [k, arr] of buckets) { const keep = arr.filter((t) => now - t < 3600000); if (keep.length) buckets.set(k, keep); else buckets.delete(k); } }
|
||
|
|
/** 창 안의 요청 수가 max 이상이면 true (그 요청은 거부). 거부된 요청도 횟수에 넣는다(연타 방지) */
|
||
|
|
function limited(key, max, windowMs) { sweep(); const now = Date.now(); const arr = (buckets.get(key) || []).filter((t) => now - t < windowMs); arr.push(now); buckets.set(key, arr); return arr.length > max; }
|
||
|
|
function count(key, windowMs) { const now = Date.now(); return (buckets.get(key) || []).filter((t) => now - t < windowMs).length; }
|
||
|
|
function reset(key) { buckets.delete(key); }
|
||
|
|
/** 설정의 신뢰 프록시 목록 */
|
||
|
|
function trustedProxies() { try { const v = require('./config').get('service.trustedProxies'); if (v && String(v).trim()) return String(v).split(/[,\s]+/).filter(Boolean); } catch (_) {} return ['127.0.0.1', '::1', '::ffff:127.0.0.1']; }
|
||
|
|
const norm = (ip) => String(ip || '').replace(/^::ffff:/, '');
|
||
|
|
function clientIp(req) {
|
||
|
|
const peer = norm(req.socket && req.socket.remoteAddress); const trusted = trustedProxies().map(norm);
|
||
|
|
if (!trusted.includes(peer)) return peer;
|
||
|
|
const xff = String(req.headers['x-forwarded-for'] || '').split(',').map((s) => norm(s.trim())).filter(Boolean);
|
||
|
|
// 오른쪽부터 신뢰 프록시를 벗겨 내고 처음 만나는 비신뢰 주소가 클라이언트
|
||
|
|
for (let i = xff.length - 1; i >= 0; i--) if (!trusted.includes(xff[i])) return xff[i];
|
||
|
|
return xff[0] || peer;
|
||
|
|
}
|
||
|
|
module.exports = { limited, count, reset, clientIp, trustedProxies };
|