diff --git a/public/index.html b/public/index.html index 0d37fb9..9436e2d 100644 --- a/public/index.html +++ b/public/index.html @@ -14,6 +14,12 @@ (function () { const savedTheme = localStorage.getItem('theme'); const settings = window.SITE_SETTINGS || {}; + const sanitizeAssetUrl = (url) => { + if (!url || typeof url !== 'string') return null; + const trimmed = url.trim(); + if (!trimmed) return null; + return /^(https?:|data:image\/|\/)/i.test(trimmed) ? trimmed : null; + }; const defaultTheme = settings.default_theme || 'dark'; let theme = savedTheme || defaultTheme; @@ -30,9 +36,10 @@ document.title = settings.page_name; } - if (settings.favicon_url) { + const safeFaviconUrl = sanitizeAssetUrl(settings.favicon_url); + if (safeFaviconUrl) { const link = document.getElementById('siteFavicon'); - if (link) link.href = settings.favicon_url; + if (link) link.href = safeFaviconUrl; } // Advanced Anti-Flicker: Wait for header elements to appear @@ -51,9 +58,13 @@ if (logoIcon) { const actualTheme = document.documentElement.classList.contains('light-theme') ? 'light' : 'dark'; - const logoToUse = (actualTheme === 'dark' && settings.logo_url_dark) ? settings.logo_url_dark : (settings.logo_url || null); + const logoToUse = sanitizeAssetUrl((actualTheme === 'dark' && settings.logo_url_dark) ? settings.logo_url_dark : (settings.logo_url || null)); if (logoToUse) { - logoIcon.innerHTML = 'Logo'; + const img = document.createElement('img'); + img.src = logoToUse; + img.alt = 'Logo'; + img.className = 'logo-icon-img'; + logoIcon.replaceChildren(img); } else { // Only if we REALLY have no logo URL, we show the default SVG fallback // (But since it's already in HTML, we just don't touch it or we show it if we hid it) diff --git a/public/js/app.js b/public/js/app.js index 708bbba..04dfefe 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -623,10 +623,11 @@ } function updateFavicon(url) { - if (!url) return; + const safeUrl = sanitizeAssetUrl(url); + if (!safeUrl) return; const link = dom.siteFavicon || document.querySelector("link[rel*='icon']"); if (link) { - link.href = url; + link.href = safeUrl; } } @@ -675,6 +676,51 @@ } } + function promptLogin(message = '该操作需要登录') { + openLoginModal(); + if (message) { + dom.loginError.textContent = message; + dom.loginError.style.display = 'block'; + } + } + + function sanitizeAssetUrl(url) { + if (!url || typeof url !== 'string') return null; + const trimmed = url.trim(); + if (!trimmed) return null; + if (/^(https?:|data:image\/|\/)/i.test(trimmed)) return trimmed; + return null; + } + + function renderLogoImage(url) { + if (!dom.logoIconContainer) return; + const safeUrl = sanitizeAssetUrl(url); + + if (safeUrl) { + const img = document.createElement('img'); + img.src = safeUrl; + img.alt = 'Logo'; + img.className = 'logo-icon-img'; + dom.logoIconContainer.replaceChildren(img); + return; + } + + dom.logoIconContainer.innerHTML = ` + + + + + + + + + + + + + `; + } + function openLoginModal() { dom.loginModal.classList.add('active'); dom.loginError.style.display = 'none'; @@ -1479,6 +1525,11 @@ try { const url = `/api/metrics/server-details?instance=${encodeURIComponent(instance)}&job=${encodeURIComponent(job)}&source=${encodeURIComponent(source)}`; const response = await fetch(url); + if (response.status === 401) { + closeServerDetail(); + promptLogin('登录后可查看服务器详细指标'); + return; + } if (!response.ok) throw new Error('Fetch failed'); const data = await response.json(); currentServerDetail.memTotal = data.memTotal; @@ -1697,6 +1748,10 @@ } const res = await fetch(url); + if (res.status === 401) { + promptLogin('登录后可查看历史曲线与服务器详细信息'); + return; + } if (!res.ok) throw new Error('Query failed'); const data = await res.json(); @@ -1883,25 +1938,7 @@ logoToUse = settings.logo_url_dark; } - if (logoToUse) { - dom.logoIconContainer.innerHTML = `Logo`; - } else { - // Restore default SVG - dom.logoIconContainer.innerHTML = ` - - - - - - - - - - - - - `; - } + renderLogoImage(logoToUse || null); // Favicon updateFavicon(settings.favicon_url); @@ -2021,6 +2058,10 @@ async function loadLatencyRoutes() { try { const response = await fetch('/api/latency-routes'); + if (response.status === 401) { + promptLogin('登录后可管理延迟线路'); + return; + } const routes = await response.json(); renderLatencyRoutes(routes); } catch (err) { @@ -2253,6 +2294,10 @@ async function loadSources() { try { const response = await fetch('/api/sources'); + if (response.status === 401) { + promptLogin('登录后可查看和管理数据源'); + return; + } const sources = await response.json(); const sourcesArray = Array.isArray(sources) ? sources : []; const promSources = sourcesArray.filter(s => s.type !== 'blackbox'); @@ -2484,6 +2529,9 @@ async function loadSourceCount() { try { const response = await fetch('/api/sources'); + if (response.status === 401) { + return; + } const sources = await response.json(); const sourcesArray = Array.isArray(sources) ? sources : []; const promSources = sourcesArray.filter(s => s.type !== 'blackbox'); diff --git a/server/index.js b/server/index.js index 505d9b9..ddf4151 100644 --- a/server/index.js +++ b/server/index.js @@ -10,7 +10,6 @@ const latencyService = require('./latency-service'); const checkAndFixDatabase = require('./db-integrity-check'); const http = require('http'); const WebSocket = require('ws'); -const dns = require('dns').promises; const net = require('net'); const app = express(); @@ -24,10 +23,15 @@ const crypto = require('crypto'); let isDbInitialized = false; const sessions = new Map(); // Fallback session store when Valkey is unavailable +const requestBuckets = new Map(); const SESSION_TTL_SECONDS = parseInt(process.env.SESSION_TTL_SECONDS, 10) || 86400; const PASSWORD_ITERATIONS = parseInt(process.env.PASSWORD_ITERATIONS, 10) || 210000; const ALLOW_REMOTE_SETUP = process.env.ALLOW_REMOTE_SETUP === 'true'; const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true'; +const RATE_LIMITS = { + login: { windowMs: 15 * 60 * 1000, max: 8 }, + setup: { windowMs: 10 * 60 * 1000, max: 20 } +}; function normalizeIp(ip) { if (!ip) return ''; @@ -60,14 +64,87 @@ function isPrivateOrLoopbackIp(ip) { } function isLocalRequest(req) { - const forwardedFor = req.headers['x-forwarded-for']; - const candidate = Array.isArray(forwardedFor) - ? forwardedFor[0] - : (typeof forwardedFor === 'string' ? forwardedFor.split(',')[0].trim() : ''); - const ip = normalizeIp(candidate || req.ip || req.socket.remoteAddress || ''); + const ip = normalizeIp(req.socket.remoteAddress || req.ip || ''); return isPrivateOrLoopbackIp(ip); } +function getClientIp(req) { + return normalizeIp(req.socket?.remoteAddress || req.ip || ''); +} + +function makeRateLimitKey(scope, req, discriminator = '') { + return `${scope}:${getClientIp(req)}:${discriminator}`; +} + +function checkRateLimit(key, { windowMs, max }) { + const now = Date.now(); + const bucket = requestBuckets.get(key); + + if (!bucket || bucket.resetAt <= now) { + requestBuckets.set(key, { count: 1, resetAt: now + windowMs }); + return { allowed: true, retryAfterMs: 0 }; + } + + if (bucket.count >= max) { + return { + allowed: false, + retryAfterMs: Math.max(0, bucket.resetAt - now) + }; + } + + bucket.count += 1; + return { allowed: true, retryAfterMs: 0 }; +} + +function enforceRateLimit(req, res, scope, discriminator = '') { + const result = checkRateLimit(makeRateLimitKey(scope, req, discriminator), RATE_LIMITS[scope]); + if (result.allowed) { + return true; + } + + res.setHeader('Retry-After', Math.max(1, Math.ceil(result.retryAfterMs / 1000))); + res.status(429).json({ + success: false, + error: 'Too many requests. Please try again later.' + }); + return false; +} + +function escapeJsonForInlineScript(value) { + return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (char) => { + switch (char) { + case '<': + return '\\u003c'; + case '>': + return '\\u003e'; + case '&': + return '\\u0026'; + case '\u2028': + return '\\u2028'; + case '\u2029': + return '\\u2029'; + default: + return char; + } + }); +} + +function getPublicSiteSettings(settings = {}) { + return { + page_name: settings.page_name || '', + show_page_name: settings.show_page_name !== undefined ? settings.show_page_name : 1, + title: settings.title || '', + logo_url: settings.logo_url || null, + logo_url_dark: settings.logo_url_dark || null, + favicon_url: settings.favicon_url || null, + default_theme: settings.default_theme || 'dark', + show_95_bandwidth: settings.show_95_bandwidth ? 1 : 0, + p95_type: settings.p95_type || 'tx', + icp_filing: settings.icp_filing || null, + ps_filing: settings.ps_filing || null + }; +} + function getCookieOptions(req, maxAgeSeconds) { const options = ['Path=/', 'HttpOnly', 'SameSite=Strict']; if (typeof maxAgeSeconds === 'number') { @@ -166,6 +243,9 @@ async function destroySession(sessionId) { } async function ensureSetupAccess(req, res, next) { + if (!enforceRateLimit(req, res, 'setup')) { + return; + } if (ALLOW_REMOTE_SETUP || isLocalRequest(req)) { return next(); } @@ -209,11 +289,11 @@ async function checkDb() { checkDb(); // --- Health API --- -app.get('/health', async (req, res) => { - try { - const dbStatus = await db.checkHealth(); - const cacheStatus = await cache.checkHealth(); - const isAllOk = dbStatus.status === 'up' && cacheStatus.status === 'up'; +app.get('/health', async (req, res) => { + try { + const dbStatus = await db.checkHealth(); + const cacheStatus = await cache.checkHealth(); + const isAllOk = dbStatus.status === 'up' && cacheStatus.status === 'up'; const healthInfo = { status: isAllOk ? 'ok' : 'error', @@ -228,18 +308,18 @@ app.get('/health', async (req, res) => { node_version: process.version }, checks: { - database: { - name: 'MySQL', - status: dbStatus.status, - message: dbStatus.error || 'Connected' - }, - valkey: { - name: 'Valkey (Redis)', - status: cacheStatus.status, - message: cacheStatus.error || 'Connected' - } - } - }; + database: { + name: 'MySQL', + status: dbStatus.status, + message: dbStatus.status === 'up' ? 'Connected' : 'Unavailable' + }, + valkey: { + name: 'Valkey (Redis)', + status: cacheStatus.status, + message: cacheStatus.status === 'up' ? 'Connected' : 'Unavailable' + } + } + }; if (isAllOk) { res.json(healthInfo); @@ -252,9 +332,13 @@ app.get('/health', async (req, res) => { }); // --- Auth API --- -app.post('/api/auth/login', async (req, res) => { - const { username, password } = req.body; - try { +app.post('/api/auth/login', async (req, res) => { + const { username, password } = req.body; + const loginKey = String(username || '').trim().toLowerCase(); + if (!enforceRateLimit(req, res, 'login', loginKey)) { + return; + } + try { const [rows] = await db.query('SELECT * FROM users WHERE username = ?', [username]); if (rows.length === 0) return res.status(401).json({ error: 'Invalid credentials' }); @@ -515,7 +599,7 @@ ENABLE_EXTERNAL_GEO_LOOKUP=${process.env.ENABLE_EXTERNAL_GEO_LOOKUP || 'false'} }); // Setup Status Check -app.get('/api/setup/status', async (req, res) => { +app.get('/api/setup/status', ensureSetupAccess, async (req, res) => { try { if (!isDbInitialized) { return res.json({ initialized: false, step: 'db' }); @@ -628,8 +712,8 @@ const serveIndex = async (req, res) => { } // Inject settings - const settingsJson = JSON.stringify(settings); - const injection = ``; + const settingsJson = escapeJsonForInlineScript(getPublicSiteSettings(settings)); + const injection = ``; // Replace with + injection html = html.replace('', '' + injection); @@ -649,7 +733,7 @@ app.use(express.static(path.join(__dirname, '..', 'public'), { index: false })); // ==================== Prometheus Source CRUD ==================== // Get all Prometheus sources -app.get('/api/sources', async (req, res) => { +app.get('/api/sources', requireAuth, async (req, res) => { try { const [rows] = await db.query('SELECT * FROM prometheus_sources ORDER BY is_server_source DESC, created_at DESC'); // Test connectivity for each source @@ -754,11 +838,15 @@ app.post('/api/sources/test', requireAuth, async (req, res) => { // ==================== Site Settings ==================== // Get site settings -app.get('/api/settings', async (req, res) => { - try { - const [rows] = await db.query('SELECT * FROM site_settings WHERE id = 1'); - if (rows.length === 0) { - return res.json({ +app.get('/api/settings', async (req, res) => { + try { + const [rows] = await db.query('SELECT * FROM site_settings WHERE id = 1'); + if (rows.length === 0) { + return res.json(getPublicSiteSettings()); + } + return res.json(getPublicSiteSettings(rows[0])); + if (rows.length === 0) { + return res.json({ page_name: '数据可视化展示大屏', show_page_name: 1, title: '数据可视化展示大屏', @@ -1065,7 +1153,7 @@ app.get('/api/metrics/cpu-history', async (req, res) => { }); // Get detailed metrics for a specific server -app.get('/api/metrics/server-details', async (req, res) => { +app.get('/api/metrics/server-details', requireAuth, async (req, res) => { const { instance, job, source } = req.query; if (!instance || !job || !source) { @@ -1090,7 +1178,7 @@ app.get('/api/metrics/server-details', async (req, res) => { }); // Get historical metrics for a specific server -app.get('/api/metrics/server-history', async (req, res) => { +app.get('/api/metrics/server-history', requireAuth, async (req, res) => { const { instance, job, source, metric, range, start, end } = req.query; if (!instance || !job || !source || !metric) { @@ -1123,7 +1211,7 @@ app.get('*', (req, res, next) => { // ==================== Latency Routes CRUD ==================== -app.get('/api/latency-routes', async (req, res) => { +app.get('/api/latency-routes', requireAuth, async (req, res) => { try { const [rows] = await db.query(` SELECT r.*, s.name as source_name diff --git a/server/prometheus-service.js b/server/prometheus-service.js index 9a10d3b..af45f04 100644 --- a/server/prometheus-service.js +++ b/server/prometheus-service.js @@ -7,10 +7,10 @@ const QUERY_TIMEOUT = 10000; // Reusable agents to handle potential redirect issues and protocol mismatches const crypto = require('crypto'); const httpAgent = new http.Agent({ keepAlive: true }); -const httpsAgent = new https.Agent({ keepAlive: true, rejectUnauthorized: false }); +const httpsAgent = new https.Agent({ keepAlive: true }); const serverIdMap = new Map(); // token -> { instance, job, source } -const SECRET = process.env.APP_SECRET || 'prom-data-panel-stable-secret-key-123'; +const SECRET = process.env.APP_SECRET || crypto.randomBytes(32).toString('hex'); function getServerToken(instance, job, source) { const hash = crypto.createHmac('sha256', SECRET)