修复安全边界问题

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 () {
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 = '<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 {
// 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)

View File

@@ -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 = `
<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() {
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 = `<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>
`;
}
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');

View File

@@ -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();
}
@@ -231,12 +311,12 @@ app.get('/health', async (req, res) => {
database: {
name: 'MySQL',
status: dbStatus.status,
message: dbStatus.error || 'Connected'
message: dbStatus.status === 'up' ? 'Connected' : 'Unavailable'
},
valkey: {
name: 'Valkey (Redis)',
status: cacheStatus.status,
message: cacheStatus.error || 'Connected'
message: cacheStatus.status === 'up' ? 'Connected' : 'Unavailable'
}
}
};
@@ -254,6 +334,10 @@ app.get('/health', async (req, res) => {
// --- Auth API ---
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,7 +712,7 @@ const serveIndex = async (req, res) => {
}
// Inject settings
const settingsJson = JSON.stringify(settings);
const settingsJson = escapeJsonForInlineScript(getPublicSiteSettings(settings));
const injection = `<script>window.SITE_SETTINGS = ${settingsJson};</script>`;
// Replace <head> with <head> + 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
@@ -757,6 +841,10 @@ app.post('/api/sources/test', requireAuth, async (req, res) => {
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: '数据可视化展示大屏',
@@ -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', requireAuth, async (req, res) => {
try {
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
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)