修复安全边界问题

This commit is contained in:
CN-JS-HuiBai
2026-04-09 13:37:47 +08:00
parent 09f20ec81d
commit 60d8a3d550
4 changed files with 212 additions and 65 deletions

View File

@@ -14,6 +14,12 @@
(function () { (function () {
const savedTheme = localStorage.getItem('theme'); const savedTheme = localStorage.getItem('theme');
const settings = window.SITE_SETTINGS || {}; 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'; const defaultTheme = settings.default_theme || 'dark';
let theme = savedTheme || defaultTheme; let theme = savedTheme || defaultTheme;
@@ -30,9 +36,10 @@
document.title = settings.page_name; document.title = settings.page_name;
} }
if (settings.favicon_url) { const safeFaviconUrl = sanitizeAssetUrl(settings.favicon_url);
if (safeFaviconUrl) {
const link = document.getElementById('siteFavicon'); 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 // Advanced Anti-Flicker: Wait for header elements to appear
@@ -51,9 +58,13 @@
if (logoIcon) { if (logoIcon) {
const actualTheme = document.documentElement.classList.contains('light-theme') ? 'light' : 'dark'; 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) { if (logoToUse) {
logoIcon.innerHTML = '<img src="' + logoToUse + '" alt="Logo" class="logo-icon-img">'; const img = document.createElement('img');
img.src = logoToUse;
img.alt = 'Logo';
img.className = 'logo-icon-img';
logoIcon.replaceChildren(img);
} else { } else {
// Only if we REALLY have no logo URL, we show the default SVG fallback // 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) // (But since it's already in HTML, we just don't touch it or we show it if we hid it)

View File

@@ -623,10 +623,11 @@
} }
function updateFavicon(url) { function updateFavicon(url) {
if (!url) return; const safeUrl = sanitizeAssetUrl(url);
if (!safeUrl) return;
const link = dom.siteFavicon || document.querySelector("link[rel*='icon']"); const link = dom.siteFavicon || document.querySelector("link[rel*='icon']");
if (link) { 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 = `
<svg class="logo-icon" id="logoSvg" viewBox="0 0 32 32" fill="none">
<rect x="2" y="2" width="28" height="28" rx="8" stroke="url(#logoGrad)" stroke-width="2.5"/>
<path d="M8 22 L12 14 L16 18 L20 10 L24 16" stroke="url(#logoGrad)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="12" cy="14" r="2" fill="url(#logoGrad)"/>
<circle cx="20" cy="10" r="2" fill="url(#logoGrad)"/>
<defs>
<linearGradient id="logoGrad" x1="0" y1="0" x2="32" y2="32">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#06b6d4"/>
</linearGradient>
</defs>
</svg>
`;
}
function openLoginModal() { function openLoginModal() {
dom.loginModal.classList.add('active'); dom.loginModal.classList.add('active');
dom.loginError.style.display = 'none'; dom.loginError.style.display = 'none';
@@ -1479,6 +1525,11 @@
try { try {
const url = `/api/metrics/server-details?instance=${encodeURIComponent(instance)}&job=${encodeURIComponent(job)}&source=${encodeURIComponent(source)}`; const url = `/api/metrics/server-details?instance=${encodeURIComponent(instance)}&job=${encodeURIComponent(job)}&source=${encodeURIComponent(source)}`;
const response = await fetch(url); const response = await fetch(url);
if (response.status === 401) {
closeServerDetail();
promptLogin('登录后可查看服务器详细指标');
return;
}
if (!response.ok) throw new Error('Fetch failed'); if (!response.ok) throw new Error('Fetch failed');
const data = await response.json(); const data = await response.json();
currentServerDetail.memTotal = data.memTotal; currentServerDetail.memTotal = data.memTotal;
@@ -1697,6 +1748,10 @@
} }
const res = await fetch(url); const res = await fetch(url);
if (res.status === 401) {
promptLogin('登录后可查看历史曲线与服务器详细信息');
return;
}
if (!res.ok) throw new Error('Query failed'); if (!res.ok) throw new Error('Query failed');
const data = await res.json(); const data = await res.json();
@@ -1883,25 +1938,7 @@
logoToUse = settings.logo_url_dark; logoToUse = settings.logo_url_dark;
} }
if (logoToUse) { renderLogoImage(logoToUse || null);
dom.logoIconContainer.innerHTML = `<img src="${escapeHtml(logoToUse)}" alt="Logo" class="logo-icon-img">`;
} else {
// Restore default SVG
dom.logoIconContainer.innerHTML = `
<svg class="logo-icon" id="logoSvg" viewBox="0 0 32 32" fill="none">
<rect x="2" y="2" width="28" height="28" rx="8" stroke="url(#logoGrad)" stroke-width="2.5"/>
<path d="M8 22 L12 14 L16 18 L20 10 L24 16" stroke="url(#logoGrad)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<circle cx="12" cy="14" r="2" fill="url(#logoGrad)"/>
<circle cx="20" cy="10" r="2" fill="url(#logoGrad)"/>
<defs>
<linearGradient id="logoGrad" x1="0" y1="0" x2="32" y2="32">
<stop offset="0%" stop-color="#6366f1"/>
<stop offset="100%" stop-color="#06b6d4"/>
</linearGradient>
</defs>
</svg>
`;
}
// Favicon // Favicon
updateFavicon(settings.favicon_url); updateFavicon(settings.favicon_url);
@@ -2021,6 +2058,10 @@
async function loadLatencyRoutes() { async function loadLatencyRoutes() {
try { try {
const response = await fetch('/api/latency-routes'); const response = await fetch('/api/latency-routes');
if (response.status === 401) {
promptLogin('登录后可管理延迟线路');
return;
}
const routes = await response.json(); const routes = await response.json();
renderLatencyRoutes(routes); renderLatencyRoutes(routes);
} catch (err) { } catch (err) {
@@ -2253,6 +2294,10 @@
async function loadSources() { async function loadSources() {
try { try {
const response = await fetch('/api/sources'); const response = await fetch('/api/sources');
if (response.status === 401) {
promptLogin('登录后可查看和管理数据源');
return;
}
const sources = await response.json(); const sources = await response.json();
const sourcesArray = Array.isArray(sources) ? sources : []; const sourcesArray = Array.isArray(sources) ? sources : [];
const promSources = sourcesArray.filter(s => s.type !== 'blackbox'); const promSources = sourcesArray.filter(s => s.type !== 'blackbox');
@@ -2484,6 +2529,9 @@
async function loadSourceCount() { async function loadSourceCount() {
try { try {
const response = await fetch('/api/sources'); const response = await fetch('/api/sources');
if (response.status === 401) {
return;
}
const sources = await response.json(); const sources = await response.json();
const sourcesArray = Array.isArray(sources) ? sources : []; const sourcesArray = Array.isArray(sources) ? sources : [];
const promSources = sourcesArray.filter(s => s.type !== 'blackbox'); const promSources = sourcesArray.filter(s => s.type !== 'blackbox');

View File

@@ -10,7 +10,6 @@ const latencyService = require('./latency-service');
const checkAndFixDatabase = require('./db-integrity-check'); const checkAndFixDatabase = require('./db-integrity-check');
const http = require('http'); const http = require('http');
const WebSocket = require('ws'); const WebSocket = require('ws');
const dns = require('dns').promises;
const net = require('net'); const net = require('net');
const app = express(); const app = express();
@@ -24,10 +23,15 @@ const crypto = require('crypto');
let isDbInitialized = false; let isDbInitialized = false;
const sessions = new Map(); // Fallback session store when Valkey is unavailable 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 SESSION_TTL_SECONDS = parseInt(process.env.SESSION_TTL_SECONDS, 10) || 86400;
const PASSWORD_ITERATIONS = parseInt(process.env.PASSWORD_ITERATIONS, 10) || 210000; const PASSWORD_ITERATIONS = parseInt(process.env.PASSWORD_ITERATIONS, 10) || 210000;
const ALLOW_REMOTE_SETUP = process.env.ALLOW_REMOTE_SETUP === 'true'; const ALLOW_REMOTE_SETUP = process.env.ALLOW_REMOTE_SETUP === 'true';
const COOKIE_SECURE = process.env.COOKIE_SECURE === '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) { function normalizeIp(ip) {
if (!ip) return ''; if (!ip) return '';
@@ -60,14 +64,87 @@ function isPrivateOrLoopbackIp(ip) {
} }
function isLocalRequest(req) { function isLocalRequest(req) {
const forwardedFor = req.headers['x-forwarded-for']; const ip = normalizeIp(req.socket.remoteAddress || req.ip || '');
const candidate = Array.isArray(forwardedFor)
? forwardedFor[0]
: (typeof forwardedFor === 'string' ? forwardedFor.split(',')[0].trim() : '');
const ip = normalizeIp(candidate || req.ip || req.socket.remoteAddress || '');
return isPrivateOrLoopbackIp(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) { function getCookieOptions(req, maxAgeSeconds) {
const options = ['Path=/', 'HttpOnly', 'SameSite=Strict']; const options = ['Path=/', 'HttpOnly', 'SameSite=Strict'];
if (typeof maxAgeSeconds === 'number') { if (typeof maxAgeSeconds === 'number') {
@@ -166,6 +243,9 @@ async function destroySession(sessionId) {
} }
async function ensureSetupAccess(req, res, next) { async function ensureSetupAccess(req, res, next) {
if (!enforceRateLimit(req, res, 'setup')) {
return;
}
if (ALLOW_REMOTE_SETUP || isLocalRequest(req)) { if (ALLOW_REMOTE_SETUP || isLocalRequest(req)) {
return next(); return next();
} }
@@ -209,11 +289,11 @@ async function checkDb() {
checkDb(); checkDb();
// --- Health API --- // --- Health API ---
app.get('/health', async (req, res) => { app.get('/health', async (req, res) => {
try { try {
const dbStatus = await db.checkHealth(); const dbStatus = await db.checkHealth();
const cacheStatus = await cache.checkHealth(); const cacheStatus = await cache.checkHealth();
const isAllOk = dbStatus.status === 'up' && cacheStatus.status === 'up'; const isAllOk = dbStatus.status === 'up' && cacheStatus.status === 'up';
const healthInfo = { const healthInfo = {
status: isAllOk ? 'ok' : 'error', status: isAllOk ? 'ok' : 'error',
@@ -228,18 +308,18 @@ app.get('/health', async (req, res) => {
node_version: process.version node_version: process.version
}, },
checks: { checks: {
database: { database: {
name: 'MySQL', name: 'MySQL',
status: dbStatus.status, status: dbStatus.status,
message: dbStatus.error || 'Connected' message: dbStatus.status === 'up' ? 'Connected' : 'Unavailable'
}, },
valkey: { valkey: {
name: 'Valkey (Redis)', name: 'Valkey (Redis)',
status: cacheStatus.status, status: cacheStatus.status,
message: cacheStatus.error || 'Connected' message: cacheStatus.status === 'up' ? 'Connected' : 'Unavailable'
} }
} }
}; };
if (isAllOk) { if (isAllOk) {
res.json(healthInfo); res.json(healthInfo);
@@ -252,9 +332,13 @@ app.get('/health', async (req, res) => {
}); });
// --- Auth API --- // --- Auth API ---
app.post('/api/auth/login', async (req, res) => { app.post('/api/auth/login', async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
try { 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]); const [rows] = await db.query('SELECT * FROM users WHERE username = ?', [username]);
if (rows.length === 0) return res.status(401).json({ error: 'Invalid credentials' }); 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 // Setup Status Check
app.get('/api/setup/status', async (req, res) => { app.get('/api/setup/status', ensureSetupAccess, async (req, res) => {
try { try {
if (!isDbInitialized) { if (!isDbInitialized) {
return res.json({ initialized: false, step: 'db' }); return res.json({ initialized: false, step: 'db' });
@@ -628,8 +712,8 @@ const serveIndex = async (req, res) => {
} }
// Inject settings // Inject settings
const settingsJson = JSON.stringify(settings); const settingsJson = escapeJsonForInlineScript(getPublicSiteSettings(settings));
const injection = `<script>window.SITE_SETTINGS = ${settingsJson};</script>`; const injection = `<script>window.SITE_SETTINGS = ${settingsJson};</script>`;
// Replace <head> with <head> + injection // Replace <head> with <head> + injection
html = html.replace('<head>', '<head>' + injection); html = html.replace('<head>', '<head>' + injection);
@@ -649,7 +733,7 @@ app.use(express.static(path.join(__dirname, '..', 'public'), { index: false }));
// ==================== Prometheus Source CRUD ==================== // ==================== Prometheus Source CRUD ====================
// Get all Prometheus sources // Get all Prometheus sources
app.get('/api/sources', async (req, res) => { app.get('/api/sources', requireAuth, async (req, res) => {
try { try {
const [rows] = await db.query('SELECT * FROM prometheus_sources ORDER BY is_server_source DESC, created_at DESC'); const [rows] = await db.query('SELECT * FROM prometheus_sources ORDER BY is_server_source DESC, created_at DESC');
// Test connectivity for each source // Test connectivity for each source
@@ -754,11 +838,15 @@ app.post('/api/sources/test', requireAuth, async (req, res) => {
// ==================== Site Settings ==================== // ==================== Site Settings ====================
// Get site settings // Get site settings
app.get('/api/settings', async (req, res) => { app.get('/api/settings', async (req, res) => {
try { try {
const [rows] = await db.query('SELECT * FROM site_settings WHERE id = 1'); const [rows] = await db.query('SELECT * FROM site_settings WHERE id = 1');
if (rows.length === 0) { if (rows.length === 0) {
return res.json({ return res.json(getPublicSiteSettings());
}
return res.json(getPublicSiteSettings(rows[0]));
if (rows.length === 0) {
return res.json({
page_name: '数据可视化展示大屏', page_name: '数据可视化展示大屏',
show_page_name: 1, show_page_name: 1,
title: '数据可视化展示大屏', title: '数据可视化展示大屏',
@@ -1065,7 +1153,7 @@ app.get('/api/metrics/cpu-history', async (req, res) => {
}); });
// Get detailed metrics for a specific server // 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; const { instance, job, source } = req.query;
if (!instance || !job || !source) { if (!instance || !job || !source) {
@@ -1090,7 +1178,7 @@ app.get('/api/metrics/server-details', async (req, res) => {
}); });
// Get historical metrics for a specific server // 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; const { instance, job, source, metric, range, start, end } = req.query;
if (!instance || !job || !source || !metric) { if (!instance || !job || !source || !metric) {
@@ -1123,7 +1211,7 @@ app.get('*', (req, res, next) => {
// ==================== Latency Routes CRUD ==================== // ==================== Latency Routes CRUD ====================
app.get('/api/latency-routes', requireAuth, async (req, res) => { app.get('/api/latency-routes', requireAuth, async (req, res) => {
try { try {
const [rows] = await db.query(` const [rows] = await db.query(`

View File

@@ -7,10 +7,10 @@ const QUERY_TIMEOUT = 10000;
// Reusable agents to handle potential redirect issues and protocol mismatches // Reusable agents to handle potential redirect issues and protocol mismatches
const crypto = require('crypto'); const crypto = require('crypto');
const httpAgent = new http.Agent({ keepAlive: true }); 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 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) { function getServerToken(instance, job, source) {
const hash = crypto.createHmac('sha256', SECRET) const hash = crypto.createHmac('sha256', SECRET)