refactor: modularize backend security and observability
This commit is contained in:
26
backend/utils/coerce.js
Normal file
26
backend/utils/coerce.js
Normal file
@@ -0,0 +1,26 @@
|
||||
function normalizeNonNegativeInteger(rawValue, fallback = 0) {
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return Math.max(0, Number(fallback) || 0);
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function parseBooleanLike(rawValue, fallback = false) {
|
||||
if (rawValue === null || rawValue === undefined || rawValue === '') {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof rawValue === 'boolean') {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
const normalized = String(rawValue).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeNonNegativeInteger,
|
||||
parseBooleanLike
|
||||
};
|
||||
68
backend/utils/device.js
Normal file
68
backend/utils/device.js
Normal file
@@ -0,0 +1,68 @@
|
||||
function normalizeClientIp(rawIp) {
|
||||
const ip = String(rawIp || '').trim();
|
||||
if (!ip) return '';
|
||||
if (ip.startsWith('::ffff:')) return ip.slice(7);
|
||||
return ip === '::1' ? '127.0.0.1' : ip;
|
||||
}
|
||||
|
||||
function detectDeviceTypeFromUserAgent(userAgent = '') {
|
||||
const mobilePattern = /(Mobile|Android|iPhone|iPad|iPod|Windows Phone|HarmonyOS|Mobi)/i;
|
||||
return mobilePattern.test(String(userAgent || '')) ? 'mobile' : 'desktop';
|
||||
}
|
||||
|
||||
function inferPlatformFromUserAgent(userAgent = '') {
|
||||
const value = String(userAgent || '');
|
||||
if (!value) return '未知平台';
|
||||
if (/windows/i.test(value)) return 'Windows';
|
||||
if (/macintosh|mac os x/i.test(value)) return 'macOS';
|
||||
if (/android/i.test(value)) return 'Android';
|
||||
if (/iphone|ipad|ios/i.test(value)) return 'iOS';
|
||||
if (/linux/i.test(value)) return 'Linux';
|
||||
return '未知平台';
|
||||
}
|
||||
|
||||
function normalizeClientType(value = '') {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return ['web', 'desktop', 'mobile', 'api'].includes(normalized) ? normalized : '';
|
||||
}
|
||||
|
||||
function resolveClientType(clientType, userAgent = '') {
|
||||
const normalized = normalizeClientType(clientType);
|
||||
if (normalized) return normalized;
|
||||
|
||||
const userAgentValue = String(userAgent || '').toLowerCase();
|
||||
if (
|
||||
userAgentValue.includes('tauri')
|
||||
|| userAgentValue.includes('electron')
|
||||
|| userAgentValue.includes('wanwan-cloud-desktop')
|
||||
|| userAgentValue.includes('玩玩云')
|
||||
) {
|
||||
return 'desktop';
|
||||
}
|
||||
return detectDeviceTypeFromUserAgent(userAgentValue) === 'mobile' ? 'mobile' : 'web';
|
||||
}
|
||||
|
||||
function sanitizeDeviceText(value, maxLength = 120) {
|
||||
return typeof value === 'string' ? value.trim().slice(0, maxLength) : '';
|
||||
}
|
||||
|
||||
function buildDeviceName({ clientType, deviceName, platform }) {
|
||||
const preferred = sanitizeDeviceText(deviceName, 120);
|
||||
if (preferred) return preferred;
|
||||
|
||||
const platformText = sanitizeDeviceText(platform, 80) || '未知平台';
|
||||
if (clientType === 'desktop') return `桌面客户端 · ${platformText}`;
|
||||
if (clientType === 'mobile') return `移动端浏览器 · ${platformText}`;
|
||||
if (clientType === 'api') return `API 客户端 · ${platformText}`;
|
||||
return `网页端浏览器 · ${platformText}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDeviceName,
|
||||
detectDeviceTypeFromUserAgent,
|
||||
inferPlatformFromUserAgent,
|
||||
normalizeClientIp,
|
||||
normalizeClientType,
|
||||
resolveClientType,
|
||||
sanitizeDeviceText
|
||||
};
|
||||
137
backend/utils/logger.js
Normal file
137
backend/utils/logger.js
Normal file
@@ -0,0 +1,137 @@
|
||||
const { AsyncLocalStorage } = require('async_hooks');
|
||||
const { randomUUID } = require('crypto');
|
||||
const pino = require('pino');
|
||||
|
||||
const requestStorage = new AsyncLocalStorage();
|
||||
const SENSITIVE_KEY_PATTERN = /(authorization|cookie|password|passwd|secret|token|api[_-]?key|access[_-]?key)/i;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
|
||||
const logger = pino({
|
||||
level: process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug'),
|
||||
base: {
|
||||
service: 'wanwanyun-backend',
|
||||
pid: process.pid
|
||||
},
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
redact: {
|
||||
paths: [
|
||||
'*.authorization',
|
||||
'*.cookie',
|
||||
'*.password',
|
||||
'*.token',
|
||||
'*.refreshToken',
|
||||
'*.access_key_secret',
|
||||
'*.oss_access_key_secret',
|
||||
'*.smtp_password',
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie'
|
||||
],
|
||||
censor: '[REDACTED]'
|
||||
}
|
||||
});
|
||||
|
||||
function redactString(value) {
|
||||
return value
|
||||
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
|
||||
.replace(/\b(password|passwd|secret|token|api[_-]?key|access[_-]?key)\s*[:=]\s*[^\s,;&]+/gi, '$1=[REDACTED]');
|
||||
}
|
||||
|
||||
function redactLogValue(value, depth = 0, seen = new WeakSet()) {
|
||||
if (depth > 5) return '[MAX_DEPTH]';
|
||||
if (typeof value === 'string') return redactString(value);
|
||||
if (value === null || value === undefined || typeof value !== 'object') return value;
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
type: value.name,
|
||||
message: redactString(value.message || ''),
|
||||
stack: redactString(value.stack || ''),
|
||||
code: value.code
|
||||
};
|
||||
}
|
||||
if (seen.has(value)) return '[CIRCULAR]';
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactLogValue(item, depth + 1, seen));
|
||||
}
|
||||
|
||||
const result = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
result[key] = SENSITIVE_KEY_PATTERN.test(key)
|
||||
? '[REDACTED]'
|
||||
: redactLogValue(child, depth + 1, seen);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getRequestContext() {
|
||||
return requestStorage.getStore() || {};
|
||||
}
|
||||
|
||||
function getRequestId(req) {
|
||||
return req?.id || getRequestContext().requestId || null;
|
||||
}
|
||||
|
||||
function normalizeRequestId(value) {
|
||||
const candidate = Array.isArray(value) ? value[0] : value;
|
||||
return typeof candidate === 'string' && REQUEST_ID_PATTERN.test(candidate)
|
||||
? candidate
|
||||
: randomUUID();
|
||||
}
|
||||
|
||||
function requestContextMiddleware(req, res, next) {
|
||||
const requestId = normalizeRequestId(req.headers['x-request-id']);
|
||||
const startedAt = process.hrtime.bigint();
|
||||
req.id = requestId;
|
||||
req.log = logger.child({ requestId });
|
||||
res.setHeader('X-Request-ID', requestId);
|
||||
|
||||
requestStorage.run({ requestId }, () => {
|
||||
res.once('finish', () => {
|
||||
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
||||
const level = res.statusCode >= 500 ? 'error' : (res.statusCode >= 400 ? 'warn' : 'info');
|
||||
req.log[level]({
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
statusCode: res.statusCode,
|
||||
durationMs: Number(durationMs.toFixed(2))
|
||||
}, 'request completed');
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
let consoleBridgeInstalled = false;
|
||||
|
||||
function installConsoleBridge() {
|
||||
if (consoleBridgeInstalled) return;
|
||||
if (process.env.NODE_ENV !== 'production' && process.env.LOG_FORMAT !== 'json') return;
|
||||
consoleBridgeInstalled = true;
|
||||
|
||||
const levels = {
|
||||
log: 'info',
|
||||
info: 'info',
|
||||
warn: 'warn',
|
||||
error: 'error',
|
||||
debug: 'debug'
|
||||
};
|
||||
|
||||
for (const [consoleMethod, level] of Object.entries(levels)) {
|
||||
console[consoleMethod] = (...args) => {
|
||||
const values = redactLogValue(args);
|
||||
const firstMessage = typeof values[0] === 'string' ? values[0] : 'console message';
|
||||
logger[level]({
|
||||
requestId: getRequestId(),
|
||||
values
|
||||
}, firstMessage);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRequestId,
|
||||
installConsoleBridge,
|
||||
logger,
|
||||
redactLogValue,
|
||||
requestContextMiddleware
|
||||
};
|
||||
115
backend/utils/oss-log-parser.js
Normal file
115
backend/utils/oss-log-parser.js
Normal file
@@ -0,0 +1,115 @@
|
||||
const MONTHS = Object.freeze({
|
||||
Jan: 0,
|
||||
Feb: 1,
|
||||
Mar: 2,
|
||||
Apr: 3,
|
||||
May: 4,
|
||||
Jun: 5,
|
||||
Jul: 6,
|
||||
Aug: 7,
|
||||
Sep: 8,
|
||||
Oct: 9,
|
||||
Nov: 10,
|
||||
Dec: 11
|
||||
});
|
||||
|
||||
function parseDownloadTrafficLogTime(line) {
|
||||
if (!line || typeof line !== 'string') return null;
|
||||
|
||||
const match = line.match(/\[(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})\s*([+-]\d{4})?\]/);
|
||||
if (!match) return null;
|
||||
|
||||
const month = MONTHS[match[2]];
|
||||
if (month === undefined) return null;
|
||||
|
||||
const year = Number(match[3]);
|
||||
const day = Number(match[1]);
|
||||
const hour = Number(match[4]);
|
||||
const minute = Number(match[5]);
|
||||
const second = Number(match[6]);
|
||||
if ([year, day, hour, minute, second].some((value) => !Number.isFinite(value))) return null;
|
||||
|
||||
let utcMillis = Date.UTC(year, month, day, hour, minute, second);
|
||||
const timezone = match[7];
|
||||
if (timezone && /^[+-]\d{4}$/.test(timezone)) {
|
||||
const sign = timezone[0] === '+' ? 1 : -1;
|
||||
const timezoneHours = Number(timezone.slice(1, 3));
|
||||
const timezoneMinutes = Number(timezone.slice(3, 5));
|
||||
if (timezoneHours > 23 || timezoneMinutes > 59) return null;
|
||||
utcMillis -= sign * ((timezoneHours * 60) + timezoneMinutes) * 60 * 1000;
|
||||
}
|
||||
|
||||
const parsed = new Date(utcMillis);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
|
||||
// Date.UTC normalizes impossible dates; reject them instead of charging the wrong day.
|
||||
const offsetMinutes = timezone
|
||||
? (timezone[0] === '+' ? 1 : -1) * ((Number(timezone.slice(1, 3)) * 60) + Number(timezone.slice(3, 5)))
|
||||
: 0;
|
||||
const localDate = new Date(parsed.getTime() + (offsetMinutes * 60 * 1000));
|
||||
if (
|
||||
localDate.getUTCFullYear() !== year
|
||||
|| localDate.getUTCMonth() !== month
|
||||
|| localDate.getUTCDate() !== day
|
||||
|| localDate.getUTCHours() !== hour
|
||||
|| localDate.getUTCMinutes() !== minute
|
||||
|| localDate.getUTCSeconds() !== second
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseDownloadTrafficLine(line, fallbackDate = new Date()) {
|
||||
if (!line || typeof line !== 'string') return null;
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !/\bGET\b/i.test(trimmed)) return null;
|
||||
|
||||
const statusMatch = trimmed.match(/"\s*(\d{3})\s+(\d+|-)\b/);
|
||||
if (!statusMatch) return null;
|
||||
|
||||
const statusCode = Number(statusMatch[1]);
|
||||
const bytesSent = statusMatch[2] === '-' ? 0 : Number(statusMatch[2]);
|
||||
if (![200, 206].includes(statusCode) || !Number.isFinite(bytesSent) || bytesSent <= 0) return null;
|
||||
|
||||
let objectKey = null;
|
||||
const requestMatch = trimmed.match(/"(?:GET|HEAD)\s+([^" ]+)\s+HTTP\//i);
|
||||
if (requestMatch?.[1]) {
|
||||
let requestPath = requestMatch[1];
|
||||
const queryIndex = requestPath.indexOf('?');
|
||||
if (queryIndex >= 0) requestPath = requestPath.slice(0, queryIndex);
|
||||
requestPath = requestPath.replace(/^https?:\/\/[^/]+/i, '').replace(/^\/+/, '');
|
||||
try {
|
||||
requestPath = decodeURIComponent(requestPath);
|
||||
} catch {
|
||||
// Keep the raw path when the log contains malformed percent encoding.
|
||||
}
|
||||
objectKey = requestPath || null;
|
||||
}
|
||||
|
||||
if (!objectKey) {
|
||||
const keyMatch = trimmed.match(/\buser_(\d+)\/[^\s"]+/);
|
||||
objectKey = keyMatch?.[0] || null;
|
||||
}
|
||||
if (!objectKey) return null;
|
||||
|
||||
const userMatch = objectKey.match(/(?:^|\/)user_(\d+)\//);
|
||||
if (!userMatch) return null;
|
||||
|
||||
const userId = Number(userMatch[1]);
|
||||
if (!Number.isSafeInteger(userId) || userId <= 0) return null;
|
||||
|
||||
return {
|
||||
userId,
|
||||
bytes: Math.floor(bytesSent),
|
||||
objectKey,
|
||||
eventAt: parseDownloadTrafficLogTime(trimmed) || fallbackDate
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseDownloadTrafficLine,
|
||||
parseDownloadTrafficLogTime
|
||||
};
|
||||
34
backend/utils/path-safety.js
Normal file
34
backend/utils/path-safety.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const path = require('path');
|
||||
|
||||
function isPathInside(parent, child) {
|
||||
const relativePath = path.relative(parent, child);
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function normalizeVirtualPath(rawPath) {
|
||||
if (typeof rawPath !== 'string') return null;
|
||||
|
||||
let decoded = rawPath;
|
||||
try {
|
||||
decoded = decodeURIComponent(rawPath);
|
||||
} catch {
|
||||
// The traversal checks below still apply to malformed encoded input.
|
||||
}
|
||||
|
||||
if (decoded.includes('\x00') || decoded.toLowerCase().includes('%00')) return null;
|
||||
|
||||
const unifiedPath = decoded.replace(/\\/g, '/');
|
||||
if (/(^|\/)\.\.(\/|$)/.test(unifiedPath)) return null;
|
||||
|
||||
let normalized = path.posix.normalize(unifiedPath);
|
||||
if (normalized === '' || normalized === '.') normalized = '/';
|
||||
if (!normalized.startsWith('/')) normalized = `/${normalized}`;
|
||||
|
||||
normalized = normalized.replace(/\/+$/g, '');
|
||||
return normalized || '/';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isPathInside,
|
||||
normalizeVirtualPath
|
||||
};
|
||||
54
backend/utils/share.js
Normal file
54
backend/utils/share.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const { normalizeVirtualPath } = require('./path-safety');
|
||||
|
||||
const SHARE_CODE_PATTERN = /^[A-Za-z0-9]{6,32}$/;
|
||||
|
||||
function isValidShareCode(code) {
|
||||
return typeof code === 'string' && SHARE_CODE_PATTERN.test(code);
|
||||
}
|
||||
|
||||
function isPathWithinShare(requestPath, share) {
|
||||
if (!requestPath || !share) return false;
|
||||
|
||||
const normalizedRequest = normalizeVirtualPath(requestPath);
|
||||
const normalizedShare = normalizeVirtualPath(share.share_path);
|
||||
if (!normalizedRequest || !normalizedShare) return false;
|
||||
|
||||
if (share.share_type === 'file') return normalizedRequest === normalizedShare;
|
||||
|
||||
const sharePrefix = normalizedShare.endsWith('/') ? normalizedShare : `${normalizedShare}/`;
|
||||
return normalizedRequest === normalizedShare || normalizedRequest.startsWith(sharePrefix);
|
||||
}
|
||||
|
||||
function parseShareIpWhitelist(rawValue) {
|
||||
if (typeof rawValue !== 'string') return [];
|
||||
return rawValue
|
||||
.split(/[\s,;]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
function isShareIpAllowed(clientIp, whitelist = []) {
|
||||
if (!Array.isArray(whitelist) || whitelist.length === 0) return true;
|
||||
if (!clientIp) return false;
|
||||
|
||||
for (const rule of whitelist) {
|
||||
const normalizedRule = String(rule || '').trim();
|
||||
if (!normalizedRule) continue;
|
||||
if (normalizedRule === clientIp) return true;
|
||||
|
||||
if (normalizedRule.endsWith('*')) {
|
||||
const prefix = normalizedRule.slice(0, -1);
|
||||
if (prefix && clientIp.startsWith(prefix)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isPathWithinShare,
|
||||
isShareIpAllowed,
|
||||
isValidShareCode,
|
||||
parseShareIpWhitelist
|
||||
};
|
||||
43
backend/utils/url.js
Normal file
43
backend/utils/url.js
Normal file
@@ -0,0 +1,43 @@
|
||||
function sanitizeHttpBaseUrl(rawValue) {
|
||||
if (!rawValue) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(rawValue);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) return null;
|
||||
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
url.pathname = url.pathname.replace(/\/+$/, '');
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildHttpDownloadUrl(rawBaseUrl, filePath) {
|
||||
const baseUrl = sanitizeHttpBaseUrl(rawBaseUrl);
|
||||
if (!baseUrl || !filePath) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
|
||||
const safeSegments = normalizedPath
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => encodeURIComponent(segment));
|
||||
const safePath = safeSegments.length ? `/${safeSegments.join('/')}` : '';
|
||||
|
||||
const basePath = url.pathname.replace(/\/+$/, '');
|
||||
url.pathname = `${basePath}${safePath || '/'}` || '/';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildHttpDownloadUrl,
|
||||
sanitizeHttpBaseUrl
|
||||
};
|
||||
45
backend/utils/version.js
Normal file
45
backend/utils/version.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
function normalizeVersion(rawVersion, fallback = '0.0.0') {
|
||||
const value = String(rawVersion || '').trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function compareLooseVersion(left, right) {
|
||||
const normalize = (value) => normalizeVersion(value, '0.0.0')
|
||||
.replace(/^v/i, '')
|
||||
.split('.')
|
||||
.map((part) => parseInt(part, 10))
|
||||
.map((num) => (Number.isFinite(num) ? num : 0));
|
||||
|
||||
const a = normalize(left);
|
||||
const b = normalize(right);
|
||||
const size = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < size; i += 1) {
|
||||
const av = a[i] || 0;
|
||||
const bv = b[i] || 0;
|
||||
if (av > bv) return 1;
|
||||
if (av < bv) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeReleaseNotes(rawValue) {
|
||||
return String(rawValue || '')
|
||||
.replace(/\\r\\n/g, '\n')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\r/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeSha256(rawValue) {
|
||||
const digest = String(rawValue || '').trim().toLowerCase();
|
||||
return SHA256_PATTERN.test(digest) ? digest : '';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compareLooseVersion,
|
||||
normalizeReleaseNotes,
|
||||
normalizeSha256,
|
||||
normalizeVersion
|
||||
};
|
||||
Reference in New Issue
Block a user