修复安全边界问题

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

@@ -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 = `<script>window.SITE_SETTINGS = ${settingsJson};</script>`;
const settingsJson = escapeJsonForInlineScript(getPublicSiteSettings(settings));
const injection = `<script>window.SITE_SETTINGS = ${settingsJson};</script>`;
// Replace <head> with <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 ====================
// 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', requireAuth, async (req, res) => {
try {
const [rows] = await db.query(`