Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
140 lines
11 KiB
JavaScript
140 lines
11 KiB
JavaScript
'use strict';
|
|
// ============================================================================
|
|
// 바깥 주소 가져오기 보호 (v1.3.17, reports/code-review-2026-10-07.md S08·S09·S12·B16)
|
|
//
|
|
// 서비스가 사용자가 준 주소(참고 사이트, 복사할 사이트, 가져온 사이트의 운영 주소)를 서버에서 내려받을 때:
|
|
// 1) 주소마다(리다이렉트 한 단계마다) DNS 를 풀어 사설·예약 주소가 하나라도 있으면 거부한다(관리자 작업만 예외).
|
|
// 2) 확인한 그 IP 로만 접속한다(lookup 고정). 확인과 접속 사이에 DNS 가 내부 주소로 바뀌는 경우(DNS 리바인딩)를 막는다.
|
|
// 3) 리다이렉트는 자동으로 따르지 않고 직접 따른다. Location 이 잘못된 값이어도 예외가 콜백 밖으로 새지 않고
|
|
// 실패 결과로 돌아온다(예전에는 응답 콜백 안에서 new URL 이 던져 Node 프로세스가 종료될 수 있었다 — S12).
|
|
// 4) 본문은 흘려받으며 바이트 상한을 넘는 순간 끊는다(전부 메모리에 받은 뒤 크기를 재던 것 — B16). gzip·br·deflate 는 풀어서 센다.
|
|
// 의존성 없음 (http/https/zlib/dns).
|
|
// ============================================================================
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const zlib = require('zlib');
|
|
const net = require('net');
|
|
const dns = require('dns').promises;
|
|
|
|
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
|
|
|
|
/** IPv4 사설·예약 대역 */
|
|
function privateV4(ip) {
|
|
const [a, b] = ip.split('.').map(Number);
|
|
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || (a === 198 && (b === 18 || b === 19)) || (a === 192 && b === 0) || a >= 224;
|
|
}
|
|
/** 사설·루프백·링크 로컬·멀티캐스트·예약 주소인지 (IPv4, IPv6, IPv4 대응 IPv6) */
|
|
function isPrivateIp(ip) {
|
|
if (net.isIPv4(ip)) return privateV4(ip);
|
|
const x = String(ip).toLowerCase();
|
|
const mapped = x.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (mapped) return privateV4(mapped[1]);
|
|
return x === '::' || x === '::1' || /^f[cd]/.test(x) || /^fe[89ab]/.test(x) || x.startsWith('ff') || x.startsWith('64:ff9b:') || x.startsWith('2001:db8:') || x.startsWith('::ffff:');
|
|
}
|
|
/**
|
|
* 호스트 이름 → 접속할 IP 하나. 사설 주소가 하나라도 섞이면(allowPrivate 가 아니면) 거부한다.
|
|
* @returns {Promise<{address: string, family: number}>}
|
|
*/
|
|
async function resolvePublic(hostname, allowPrivate) {
|
|
const h = String(hostname || '').replace(/^\[|\]$/g, '');
|
|
let addrs;
|
|
if (net.isIP(h)) addrs = [{ address: h, family: net.isIP(h) }];
|
|
else { try { addrs = await dns.lookup(h, { all: true, verbatim: true }); } catch (_) { throw httpError(`주소를 찾을 수 없습니다: ${h}`); } }
|
|
if (!addrs.length) throw httpError(`주소를 찾을 수 없습니다: ${h}`);
|
|
if (!allowPrivate && addrs.some((a) => isPrivateIp(a.address)) && !allowedInternal(h, null)) throw httpError('내부(사설) 네트워크 주소는 가져올 수 없습니다. 관리자가 허용 목록(설정 service.internalAllow)에 넣은 서비스만 가능합니다.', 403); // v1.6.31 허용 목록
|
|
return addrs[0];
|
|
}
|
|
/** http(s).request 의 lookup 을 확인한 주소 하나로 고정한다 (다시 DNS 를 풀지 않음) */
|
|
const pinned = (addr) => (host, opts, cb) => { if (typeof opts === 'function') { cb = opts; opts = {}; } if (opts && opts.all) cb(null, [{ address: addr.address, family: addr.family }]); else cb(null, addr.address, addr.family); };
|
|
|
|
/**
|
|
* 한 번 요청 (리다이렉트를 따르지 않음). 오류는 모두 reject 로만 나온다.
|
|
* @returns {Promise<{status, headers, buf, url}>}
|
|
*/
|
|
function requestOnce(u, addr, o) {
|
|
return new Promise((resolve, reject) => {
|
|
const mod = u.protocol === 'https:' ? https : http;
|
|
let done = false; const fail = (e) => { if (!done) { done = true; reject(e); } };
|
|
let req;
|
|
try {
|
|
req = mod.request(u, { method: 'GET', lookup: pinned(addr), timeout: o.timeout, rejectUnauthorized: o.rejectUnauthorized !== false, servername: net.isIP(u.hostname) ? undefined : u.hostname, headers: { 'User-Agent': o.userAgent || 'Mozilla/5.0 (compatible; AIWebmaker/1.0)', Accept: o.accept || '*/*', 'Accept-Encoding': 'gzip, deflate, br', ...(o.headers || {}) } }, (res) => {
|
|
try {
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); done = true; return resolve({ status: res.statusCode, headers: res.headers, buf: Buffer.alloc(0), url: u }); }
|
|
const enc = String(res.headers['content-encoding'] || '').toLowerCase();
|
|
let stream = res;
|
|
if (/gzip/.test(enc)) stream = res.pipe(zlib.createGunzip()); else if (/br/.test(enc)) stream = res.pipe(zlib.createBrotliDecompress()); else if (/deflate/.test(enc)) stream = res.pipe(zlib.createInflate());
|
|
const chunks = []; let size = 0;
|
|
stream.on('data', (c) => { size += c.length; if (size > o.maxBytes) { req.destroy(); fail(Object.assign(httpError(o.tooBigMessage || '응답이 너무 큽니다', 413), { tooBig: true })); return; } chunks.push(c); });
|
|
stream.on('end', () => { if (!done) { done = true; resolve({ status: res.statusCode, headers: res.headers, buf: Buffer.concat(chunks), url: u }); } });
|
|
stream.on('error', fail); res.on('error', fail);
|
|
} catch (e) { fail(e); }
|
|
});
|
|
} catch (e) { return fail(e); }
|
|
req.on('timeout', () => req.destroy(Object.assign(new Error('응답 시간 초과'), { name: 'AbortError' })));
|
|
req.on('error', fail);
|
|
if (o.signal) { if (o.signal.aborted) req.destroy(new Error('취소됨')); else o.signal.addEventListener('abort', () => req.destroy(new Error('취소됨')), { once: true }); }
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 안전하게 가져오기.
|
|
* @param {string} url
|
|
* @param {object} [o]
|
|
* allowPrivate : 관리자 작업 — 사설 주소 허용
|
|
* sameSite(url) : 리다이렉트 대상이 허용되는지 (false 면 offsite 로 끝냄, 복사하기의 "같은 사이트만")
|
|
* maxBytes : 본문 상한(압축을 푼 크기, 기본 3MB) maxRedirects: 기본 5 timeout: ms (기본 15000)
|
|
* rejectUnauthorized: 인증서 검증 (기본 true) headers·accept·userAgent
|
|
* @returns {Promise<{ok, status, type, buf, finalUrl, offsite?, error?}>} — 네트워크·주소 오류는 throw (호출 쪽 try/catch)
|
|
*/
|
|
async function safeFetch(url, o = {}) {
|
|
o = { maxBytes: 3 * 1024 * 1024, maxRedirects: 5, timeout: 15000, ...o };
|
|
let cur; try { cur = new URL(url); } catch (_) { throw httpError('주소 형식이 올바르지 않습니다'); }
|
|
for (let hop = 0; hop <= o.maxRedirects; hop += 1) {
|
|
if (!/^https?:$/.test(cur.protocol)) throw httpError('http:// 또는 https:// 주소만 가져올 수 있습니다');
|
|
if (o.sameSite && !o.sameSite(cur)) return { ok: false, status: 0, offsite: true, type: '', buf: Buffer.alloc(0), finalUrl: cur.href };
|
|
const addr = await resolvePublic(cur.hostname, o.allowPrivate); // 단계마다 다시 확인 (S08·S09)
|
|
const r = await requestOnce(cur, addr, o);
|
|
if (r.status >= 300 && r.status < 400 && r.headers.location) {
|
|
let next; try { next = new URL(r.headers.location, cur); } catch (_) { return { ok: false, status: r.status, type: '', buf: Buffer.alloc(0), finalUrl: cur.href, error: '잘못된 리다이렉트 주소' }; } // S12
|
|
cur = next; continue;
|
|
}
|
|
return { ok: r.status >= 200 && r.status < 300, status: r.status, type: String(r.headers['content-type'] || ''), buf: r.buf, finalUrl: cur.href, headers: r.headers };
|
|
}
|
|
return { ok: false, status: 310, type: '', buf: Buffer.alloc(0), finalUrl: cur.href, error: '리다이렉트가 너무 많음' };
|
|
}
|
|
|
|
|
|
// ---------- v1.6.31 (요구 5): 내부 주소 허용 목록과 범용 검증 ----------
|
|
/**
|
|
* 관리자가 허용한 내부 대상 — 설정 service.internalAllow ("host:port, host, 10.0.0.5:8080" 쉼표 구분) + 설정 파일에 등록된 연동 서비스 주소
|
|
* (source.remote/web, GITEA API, mail.url/api, AI 제공자 기본 URL 은 관리자만 적는 값이라 자동 포함). 목록에 없는 내부 서비스로는 어떤 사용자 입력 주소도 연결하지 않는다.
|
|
* @returns {Array<{host:string, port:number|null}>}
|
|
*/
|
|
function internalAllowList() {
|
|
const out = []; const add = (h, p) => { h = String(h || '').toLowerCase().replace(/^\[|\]$/g, ''); if (h) out.push({ host: h, port: p ? Number(p) : null }); };
|
|
let cfg = null; try { cfg = require('./config'); } catch (_) {}
|
|
if (cfg) {
|
|
for (const item of String(cfg.get('service.internalAllow') || '').split(/[,\s]+/).filter(Boolean)) { const m = item.match(/^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/); if (m) add(m[1], m[2]); }
|
|
for (const key of ['source.web', 'source.apiUrl', 'mail.url', 'mail.apiUrl']) { try { const u = new URL(cfg.get(key) || ''); add(u.hostname, u.port || (u.protocol === 'https:' ? 443 : 80)); } catch (_) {} }
|
|
try { const r = String(cfg.get('source.remote') || ''); const m = r.match(/@([^:/]+)/); if (m) add(m[1], null); } catch (_) {}
|
|
}
|
|
return out;
|
|
}
|
|
function allowedInternal(hostname, port) { const h = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, ''); const p = port ? Number(port) : null; return internalAllowList().some((a) => a.host === h && (a.port == null || a.port === p)); }
|
|
/**
|
|
* 사용자가 입력한 외부 주소 검증: http(s) 만, localhost/.local/.internal 금지, DNS 가 사설·예약 주소면 거절 — 단 관리자 허용 목록(host:port)은 통과.
|
|
* @returns {Promise<string>} 정규화된 주소
|
|
*/
|
|
async function assertExternalUrl(raw, o = {}) {
|
|
let u; try { u = new URL(String(raw)); } catch (_) { throw httpError('주소 형식이 올바르지 않습니다'); }
|
|
if (!(o.allowHttp ? ['http:', 'https:'] : ['https:']).includes(u.protocol)) throw httpError(`허용되지 않는 프로토콜(${u.protocol})입니다`);
|
|
const h = u.hostname.toLowerCase().replace(/^\[|\]$/g, ''); const port = u.port || (u.protocol === 'https:' ? 443 : 80);
|
|
if (allowedInternal(h, port)) return u.toString();
|
|
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) throw httpError('내부 호스트 이름(localhost/.local/.internal)으로는 연결할 수 없습니다. 관리자가 허용 목록(service.internalAllow)에 넣은 서비스만 가능합니다', 403);
|
|
await resolvePublic(h, false);
|
|
return u.toString();
|
|
}
|
|
/** ssh/ftp 처럼 URL 이 아닌 호스트:포트 검증 (가져온 사이트의 원본 서버 접속 등) */
|
|
async function assertExternalHost(host, port) { const h = String(host || '').toLowerCase(); if (allowedInternal(h, port)) return true; if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.internal')) throw httpError('내부 호스트로는 연결할 수 없습니다', 403); await resolvePublic(h, false); return true; }
|
|
module.exports = { isPrivateIp, resolvePublic, safeFetch, internalAllowList, allowedInternal, assertExternalUrl, assertExternalHost };
|