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 = '';
+ 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 = `
`;
- } 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