const express = require('express'); const http = require('http'); const https = require('https'); const zlib = require('zlib'); const dns = require('dns'); const jwt = require('jsonwebtoken'); const cheerio = require('cheerio'); const db = require('../db'); const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth'); const router = express.Router(); // ── SSRF 防护(保留既有逻辑)──────────────────────────────────────── function isBlockedHost(hostname) { if (!hostname) return true; let host = String(hostname).toLowerCase(); if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1); const mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (mapped) host = mapped[1]; if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0' || host === '0.0.0.0' || host === '::') return true; if (host.startsWith('10.')) return true; if (host.startsWith('192.168.')) return true; if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true; if (host.startsWith('169.254.')) return true; if (host.startsWith('fc00:') || host.startsWith('fd00:')) return true; if (host.startsWith('fe80:')) return true; return false; } function lookupIpv4(hostname) { return new Promise((resolve) => { dns.lookup(hostname, { family: 4, all: true }, (err, addrs) => { if (err) return resolve([]); resolve((addrs || []).map(a => a.address)); }); }); } function ipv4ToInt(ip) { const parts = String(ip).split('.'); if (parts.length !== 4) return null; let n = 0; for (const p of parts) { if (!/^\d{1,3}$/.test(p)) return null; const v = parseInt(p, 10); if (v > 255) return null; n = (n << 8) + v; } return n >>> 0; } function ipv4CidrContains(cidr, host) { const m = String(cidr).match(/^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$/); if (!m) return null; const net = ipv4ToInt(m[1]); const hostInt = ipv4ToInt(host); const prefix = parseInt(m[2], 10); if (net === null || hostInt === null || prefix < 0 || prefix > 32) return null; const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; return (net & mask) === (hostInt & mask); } function proxyAllowed(hostname) { if (!hostname) return false; let host = String(hostname).toLowerCase(); if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1); const mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (mapped) host = mapped[1]; const raw = (db.getSetting('proxy_allowed_hosts') || '').trim(); if (!raw) return false; for (const entry of raw.split(/[,;\s]+/)) { let e = entry.trim().toLowerCase(); if (!e) continue; const lastColon = e.lastIndexOf(':'); if (lastColon > 0 && e.indexOf('.') !== -1 && !e.includes('/')) e = e.slice(0, lastColon); if (e === host) return true; if (e.includes('/') && ipv4CidrContains(e, host) === true) return true; } return false; } // SSRF 校验:返回 null=放行,否则为错误信息 async function ssrfCheck(hostname) { if (isBlockedHost(hostname) && !proxyAllowed(hostname)) return '禁止访问内网地址'; const resolvedIps = await lookupIpv4(hostname); if (resolvedIps.length > 0) { const hitInternal = resolvedIps.some(ip => isBlockedHost(ip) && !proxyAllowed(ip)); if (hitInternal) return '禁止访问内网地址'; } return null; } // ── 面板解析 ───────────────────────────────────────────────────────── const RESERVED_SLUGS = new Set(['token', 'fetch']); function getPanelBySlug(slug) { return db.get('SELECT * FROM admin_links WHERE slug = ?', [String(slug || '').toLowerCase()]); } // 短 TTL 代理 token 认证 cookie 名(同源子资源鉴权用,Path=/proxy/{slug}/) function authCookieName(slug) { return 'rwp_' + String(slug).replace(/[^a-z0-9-]/g, ''); } // 手动解析 Cookie 头(项目未用 cookie-parser,req.cookies 不存在) function getCookie(req, name) { const raw = req.headers.cookie || ''; for (const part of raw.split(';')) { const idx = part.indexOf('='); if (idx === -1) continue; if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim(); } return undefined; } // 校验请求者是否为管理员:Authorization header → ?token= → rwp cookie 任一有效即可 function isAuthorized(req, slug) { const token = req.headers.authorization && req.headers.authorization.startsWith('Bearer ') ? req.headers.authorization.slice(7) : (req.query.token || getCookie(req, authCookieName(slug))); if (!token) return false; try { const p = jwt.verify(token, SECRET); if (!p || p.role !== 'admin') return false; // 短代理 token(query/cookie):带 slug 时须匹配当前面板;旧版无 slug 的 5 分钟 token 任意面板放行 if (p.proxy && p.slug && p.slug !== slug) return false; return true; } catch { return false; } } // 签发面板访问 cookie(同源子资源自动携带) function setPanelCookie(res, slug) { const token = jwt.sign({ id: 0, username: 'panel', role: 'admin', proxy: true, slug }, SECRET, { expiresIn: '12h' }); const cookie = `${authCookieName(slug)}=${token}; Path=/proxy/${slug}/; HttpOnly; SameSite=Lax; Max-Age=43200`; const setCookieHeader = res.getHeader('Set-Cookie'); if (setCookieHeader) { const arr = Array.isArray(setCookieHeader) ? setCookieHeader : [String(setCookieHeader)]; arr.push(cookie); res.setHeader('Set-Cookie', arr); } else { res.setHeader('Set-Cookie', cookie); } } // ── URL 改写 ───────────────────────────────────────────────────────── // 根相对路径 /xxx → basePath + xxx;同 host 绝对 URL → basePath + path;其余原样 function rewriteUrl(value, basePath, originHost) { const v = String(value || '').trim(); if (!v) return v; if (/^(data:|javascript:|blob:|mailto:|tel:|about:)/i.test(v)) return v; if (v.startsWith('//')) return v; // 协议相对外部资源:保持原样(base 下仍解析到外部 host) if (v.startsWith('/proxy/')) return v; // 已是代理路径 if (v.startsWith('/')) return basePath + v.slice(1); if (/^https?:\/\//i.test(v)) { try { const u = new URL(v); if (u.hostname === originHost) return basePath + u.pathname + u.search + u.hash; return v; } catch { return v; } } return v; // 相对路径: 已处理 } // srcset 逗号分隔项改写 function rewriteSrcset(value, basePath, originHost) { return String(value || '').split(',').map((part) => { const trimmed = part.trim(); if (!trimmed) return part; const m = trimmed.match(/^(\S+)(\s+.*)?$/); if (!m) return part; return rewriteUrl(m[1], basePath, originHost) + (m[2] || ''); }).join(', '); } // ── 运行时 shim(Layer 2 客户端注入)──────────────────────────────── // 在目标 JS 前执行:patch fetch/XHR/WS/EventSource/元素属性 setter/history/存储隔离 function shimScript(slug, basePath) { return ``; } // ── HTML 改写(Layer 1,cheerio 静态)────────────────────────────── function rewriteHtml(html, basePath, originHost, slug) { try { const $ = cheerio.load(html); // 指向代理前缀(带尾斜杠) if ($('base').length) { $('base').attr('href', basePath); } else { $('head').prepend(''); } // 改写根相对/同 host 属性 $('a,link,script,img,iframe,form,video,audio,source,embed,object').each((i, el) => { const $el = $(el); const tag = el.tagName; if (tag === 'a' || tag === 'link') { if ($el.attr('href')) $el.attr('href', rewriteUrl($el.attr('href'), basePath, originHost)); } if (['script', 'img', 'iframe', 'video', 'audio', 'source', 'embed'].includes(tag)) { if ($el.attr('src')) $el.attr('src', rewriteUrl($el.attr('src'), basePath, originHost)); } if (tag === 'form') { if ($el.attr('action')) $el.attr('action', rewriteUrl($el.attr('action'), basePath, originHost)); } if (tag === 'object') { if ($el.attr('data')) $el.attr('data', rewriteUrl($el.attr('data'), basePath, originHost)); } if ($el.attr('poster')) $el.attr('poster', rewriteUrl($el.attr('poster'), basePath, originHost)); if ($el.attr('srcset')) $el.attr('srcset', rewriteSrcset($el.attr('srcset'), basePath, originHost)); if ($el.attr('data-src')) $el.attr('data-src', rewriteUrl($el.attr('data-src'), basePath, originHost)); if ($el.attr('data-href')) $el.attr('data-href', rewriteUrl($el.attr('data-href'), basePath, originHost)); }); // meta[content](og 等 URL 型) $('meta[content]').each((i, el) => { const $el = $(el); const prop = String($el.attr('property') || $el.attr('name') || '').toLowerCase(); if (prop.includes('image') || prop.includes('url') || prop.includes('og:')) { $el.attr('content', rewriteUrl($el.attr('content'), basePath, originHost)); } }); // 剥 SRI integrity / crossorigin(同源代理下无意义且可能失败) $('[integrity]').removeAttr('integrity'); $('[crossorigin]').removeAttr('crossorigin'); // 剥 CSP/XFO meta $('meta[http-equiv]').each((i, el) => { const he = String($(el).attr('http-equiv') || '').toLowerCase(); if (he === 'content-security-policy' || he === 'x-frame-options' || he === 'content-script-type') $(el).remove(); }); // 注入 shim(head 最前,早于目标脚本执行) $('head').prepend(shimScript(slug, basePath)); return $.html(); } catch (e) { console.error('Proxy HTML rewrite error:', e.message); // 改写失败:至少注入 base + shim(降级) return html.replace('', `` + shimScript(slug, basePath)) || html; } } // ── 响应头处理(Layer 3)──────────────────────────────────────────── const HOP_BY_HOP = ['connection', 'keep-alive', 'transfer-encoding', 'te', 'trailer', 'upgrade', 'proxy-authenticate', 'proxy-authorization']; function rewriteCookie(cookie, basePath, isHttps) { return String(cookie).split(';').map((part, i) => { const p = part.trim(); if (i === 0) return p; // name=value const l = p.toLowerCase(); if (l.startsWith('path=')) return 'Path=' + basePath; if (l.startsWith('domain=')) return ''; // 去 Domain if (l === 'secure' && !isHttps) return ''; // http 下剥 Secure if (l.startsWith('samesite')) return 'SameSite=Lax'; return p; }).filter(Boolean).join('; '); } function rewriteLocationHeader(value, basePath, originHost) { const v = String(value || ''); if (v.startsWith('/proxy/')) return v; if (v.startsWith('/')) return basePath + v.slice(1); if (/^https?:\/\//i.test(v)) { try { const u = new URL(v); if (u.hostname === originHost) return basePath + u.pathname + u.search; } catch {} } return v; } // ── 解压响应流(支持 gzip/deflate/br)────────────────────────────── function decodeStream(stream, encoding) { const enc = String(encoding || '').toLowerCase(); if (enc === 'gzip' || enc === 'x-gzip') return stream.pipe(zlib.createGunzip()); if (enc === 'deflate') return stream.pipe(zlib.createInflate()); if (enc === 'br') return stream.pipe(zlib.createBrotliDecompress()); return stream; } // ── HTTP 代理(全方法 + body 透传)────────────────────────────────── function proxyHttp(slug, reqPath, query, req, res, panel) { let base; try { base = new URL(panel.url); } catch { return sendError(res, '面板 URL 无效'); } const basePath = '/proxy/' + slug + '/'; const client = base.protocol === 'https:' ? https : http; const targetPath = base.pathname.replace(/\/+$/, '') + reqPath + (query ? '?' + query : ''); // 转发头:剥 hop-by-hop,host 重写为目标,合并 proxy_headers const headers = {}; for (const [k, v] of Object.entries(req.headers)) { const lk = k.toLowerCase(); if (HOP_BY_HOP.includes(lk) || lk === 'host' || lk.startsWith('rwp_') || lk === 'content-length') continue; if (lk === 'cookie') { // 只透传非面板鉴权 cookie(剥 rwp_*) const kept = String(v).split(';').map(c => c.trim()).filter(c => !/^rwp_/i.test(c)).join('; '); if (kept) headers[k] = kept; continue; } headers[k] = v; } headers.host = base.host; // 自定义转发头(proxy_headers JSON) try { const ph = JSON.parse(panel.proxy_headers || '{}'); if (ph && typeof ph === 'object') { for (const [k, v] of Object.entries(ph)) { if (!/^content-length$/i.test(k) && !HOP_BY_HOP.includes(k.toLowerCase())) headers[k] = String(v); } } } catch {} const upReq = client.request({ hostname: base.hostname, port: base.port || (base.protocol === 'https:' ? 443 : 80), path: targetPath, method: req.method, headers, timeout: 20000, family: 4, // proxy_skip_tls_verify=1 时跳过 TLS 校验(内网自签面板);默认校验(公网面板) rejectUnauthorized: Number(panel.proxy_skip_tls_verify) ? false : true, }, (upRes) => { // 响应头:剥 XFO/CSP/Permissions-Policy,改写 Cookie/Location const out = {}; const isHttps = req.secure || req.protocol === 'https'; for (const [k, v] of Object.entries(upRes.headers)) { const lk = k.toLowerCase(); if (['x-frame-options', 'content-security-policy', 'permissions-policy'].includes(lk)) continue; if (lk === 'set-cookie') { out[k] = (Array.isArray(v) ? v : [v]).map(c => rewriteCookie(c, basePath, isHttps)); continue; } if (lk === 'location' || lk === 'refresh') { out[k] = rewriteLocationHeader(v, basePath, base.hostname); continue; } if (HOP_BY_HOP.includes(lk)) continue; if (lk === 'content-length' || lk === 'content-encoding') continue; // 统一转码后重算 out[k] = v; } out['Referrer-Policy'] = 'no-referrer'; out['X-Content-Type-Options'] = 'nosniff'; const ctype = (upRes.headers['content-type'] || '').toLowerCase(); const status = upRes.statusCode || 200; // HTML:解压 → 改写 → 按 identity 发送 if (ctype.includes('text/html') && (status >= 200 && status < 300)) { const chunks = []; const dec = decodeStream(upRes, upRes.headers['content-encoding']); dec.on('data', c => chunks.push(c)); dec.on('end', () => { try { let html = Buffer.concat(chunks).toString('utf8'); html = rewriteHtml(html, basePath, base.hostname, slug); setPanelCookie(res, slug); // 首次加载即下发面板 cookie,子资源同源自动携带 const buf = Buffer.from(html, 'utf8'); out['Content-Type'] = 'text/html; charset=utf-8'; out['Content-Length'] = buf.length; res.writeHead(status, out); res.end(buf); } catch (e) { console.error('Proxy HTML process error:', e.message); sendError(res, '代理响应处理失败: ' + e.message); } }); dec.on('error', (e) => { console.error('Proxy decode error:', e.message); sendError(res, '代理响应解码失败'); }); return; } // 非 HTML:原样透传(保留 content-encoding/content-length) if (upRes.headers['content-length']) out['Content-Length'] = upRes.headers['content-length']; if (upRes.headers['content-encoding']) out['Content-Encoding'] = upRes.headers['content-encoding']; res.writeHead(status, out); upRes.pipe(res); }); upReq.on('timeout', () => { upReq.destroy(); sendError(res, '代理请求超时(20秒)'); }); upReq.on('error', (e) => { sendError(res, '代理请求失败: ' + e.message); }); // body 透传(POST/PUT/PATCH):application/json 已被 express.json 消费 → 重新序列化发送; // 其余 content-type(form/multipart/raw)请求流完整 → 直接 pipe const isJsonBody = /^application\/json/i.test(String(req.headers['content-type'] || '')); if (isJsonBody && req.body !== undefined) { const body = JSON.stringify(req.body); upReq.setHeader('Content-Length', Buffer.byteLength(body)); upReq.end(body); } else { req.pipe(upReq); } } // ── WebSocket 代理(upgrade 事件用)──────────────────────────────── // 返回 true=已接管;false=不处理(调用方销毁 socket) function proxyWsUpgrade(slug, reqPath, query, request, socket, head) { const panel = getPanelBySlug(slug); if (!panel || !panel.url) return false; let base; try { base = new URL(panel.url); } catch { return false; } // SSRF 校验 if (isBlockedHost(base.hostname) && !proxyAllowed(base.hostname)) return false; const client = base.protocol === 'https:' ? https : http; const wsPath = base.pathname.replace(/\/+$/, '') + reqPath + (query ? '?' + query : ''); const headers = {}; for (const h of ['upgrade', 'connection', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-protocol', 'sec-websocket-extensions']) { if (request.headers[h]) headers[h] = request.headers[h]; } headers.host = base.host; headers.origin = base.protocol + '//' + base.host; const upReq = client.request({ hostname: base.hostname, port: base.port || (base.protocol === 'https:' ? 443 : 80), path: wsPath, method: 'GET', headers, family: 4, rejectUnauthorized: false, }); upReq.on('upgrade', (upRes, upSocket, upHead) => { // 转发 101 头:Connection/Upgrade 必须保留(Node 客户端据此识别 upgrade), // 仅剥 transfer-encoding/keep-alive 等无关 hop-by-hop let respHead = 'HTTP/1.1 101 Switching Protocols\r\n'; const skip101 = ['transfer-encoding', 'keep-alive', 'te', 'trailer', 'proxy-authenticate', 'proxy-authorization']; for (const [k, v] of Object.entries(upRes.headers)) { if (skip101.includes(k.toLowerCase())) continue; const val = Array.isArray(v) ? v.join(', ') : v; respHead += k + ': ' + val + '\r\n'; } respHead += '\r\n'; try { socket.write(respHead); if (upHead && upHead.length) socket.write(upHead); upSocket.pipe(socket); socket.pipe(upSocket); socket.on('error', () => { try { upSocket.destroy(); } catch {} }); upSocket.on('error', () => { try { socket.destroy(); } catch {} }); } catch (e) { console.error('WS proxy pipe error:', e.message); try { upSocket.destroy(); } catch {} try { socket.destroy(); } catch {} } }); upReq.on('error', (e) => { console.error('WS proxy error:', e.message); socket.destroy(); }); upReq.on('timeout', () => { upReq.destroy(); socket.destroy(); }); upReq.end(); return true; } // 供 server.js upgrade 事件调用:解析 /proxy/:slug/ 前缀的 WS 请求 function handleProxyUpgrade(request, socket, head) { try { const u = new URL(request.url, 'http://x'); const m = u.pathname.match(/^\/proxy\/([^/]+)\/?(.*)$/); if (!m) return false; const slug = m[1]; const reqPath = m[2] ? '/' + m[2] : '/'; return proxyWsUpgrade(slug, reqPath, u.search ? u.search.slice(1) : '', request, socket, head); } catch { return false; } } function sendError(res, msg) { console.error('Proxy error:', msg); const html = `

⚠️ 代理加载失败

${msg}

`; if (res && !res.headersSent) res.status(502).send(html); } // ── 路由 ───────────────────────────────────────────────────────────── // 兼容 iframe/直链的 query token 认证(旧 /fetch 用) function queryTokenAuth(req, res, next) { if (req.query.token) req.headers.authorization = 'Bearer ' + req.query.token; next(); } // 短 TTL 代理 token(保留) router.get('/token', authMiddleware, adminOnly, (req, res) => { const token = jwt.sign( { id: req.user.id, username: req.user.username, role: req.user.role, proxy: true }, SECRET, { expiresIn: '5m' } ); res.json({ token }); }); // 旧版单 URL 透传(保留兼容) router.get('/fetch', queryTokenAuth, authMiddleware, adminOnly, async (req, res) => { if (!req.query.url) return res.status(400).json({ error: '缺少 url 参数' }); let url; try { url = new URL(req.query.url); } catch { return res.status(400).json({ error: '无效的 URL' }); } const err = await ssrfCheck(url.hostname); if (err) return res.status(403).json({ error: err }); proxyLegacyFetch(url, res, '/proxy/legacy/'); }); // 旧 /fetch 的底层实现(保留原行为:HTML 注入 + shim + 剥头) function proxyLegacyFetch(url, res, basePath) { const client = url.protocol === 'https:' ? https : http; const req = client.get({ hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname + url.search, timeout: 15000, family: 4, headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': '*/*' }, rejectUnauthorized: false, }, (upRes) => { const headers = { ...upRes.headers }; delete headers['x-frame-options']; delete headers['content-security-policy']; headers['Referrer-Policy'] = 'no-referrer'; headers['X-Content-Type-Options'] = 'nosniff'; const ctype = (headers['content-type'] || '').toLowerCase(); if (ctype.includes('text/html')) { const chunks = []; upRes.on('data', c => chunks.push(c)); upRes.on('end', () => { const origin = url.hostname; let html = Buffer.concat(chunks).toString('utf8'); html = rewriteHtml(html, basePath, origin, 'legacy'); const buf = Buffer.from(html, 'utf8'); headers['Content-Length'] = buf.length; headers['Content-Type'] = 'text/html; charset=utf-8'; res.writeHead(upRes.statusCode || 200, headers); res.end(buf); }); } else { res.writeHead(upRes.statusCode || 200, headers); upRes.pipe(res); } }); req.on('error', e => sendError(res, '代理请求失败: ' + e.message)); req.on('timeout', () => { req.destroy(); sendError(res, '代理请求超时'); }); } // ── /proxy/:slug 前缀代理(核心)────────────────────────────────── // 挂载于 server.js 的 app.use('/proxy', proxyRoutes)(在 express.json 之前,保证 body 流完整) // 解析 slug 与鉴权的公共处理器 function prefixAuth(req, res, next) { const slug = req.params.slug; if (!slug || RESERVED_SLUGS.has(slug)) return sendError(res, '面板不存在'); const panel = getPanelBySlug(slug); if (!panel) return sendError(res, '面板不存在'); if (!isAuthorized(req, slug)) return res.status(403).json({ error: '未授权' }); req.panel = panel; req.proxySlug = slug; next(); } // 面板内网/SSRF 校验(异步) async function prefixCheck(req, res, next) { try { const base = new URL(req.panel.url); const err = await ssrfCheck(base.hostname); if (err) return res.status(403).json({ error: err }); next(); } catch { return sendError(res, '面板 URL 无效'); } } function prefixProxy(req, res) { const slug = req.proxySlug; const basePath = '/proxy/' + slug + '/'; // 去掉 /proxy/{slug} 前缀后的目标路径 const m = String(req.path).match(/^\/[^/]+(?:\/(.*))?$/); const reqPath = m && m[1] ? '/' + m[1] : '/'; const query = req.url.indexOf('?') !== -1 ? req.url.slice(req.url.indexOf('?') + 1) : ''; proxyHttp(slug, reqPath, query, req, res, req.panel); } router.all('/:slug', prefixAuth, prefixCheck, prefixProxy); router.all('/:slug/*', prefixAuth, prefixCheck, prefixProxy); module.exports = router; module.exports.handleProxyUpgrade = handleProxyUpgrade;