cangku/backend/src/services/selectionFissionExternalSecurity.js

123 lines
3.9 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const SIGNATURE_TOLERANCE_SECONDS = 5 * 60;
const usedNonces = new Map();
function normalizeIp(value) {
return String(value || '')
.trim()
.replace(/^::ffff:/i, '')
.replace(/^\[|\]$/g, '');
}
function resolveRequestIp(req = {}) {
const socketIp = normalizeIp(req.socket?.remoteAddress || '');
const forwarded = String(req.headers?.['x-forwarded-for'] || '')
.split(',')[0]
.trim();
const requestIp = normalizeIp(req.ip || forwarded);
return ['127.0.0.1', '::1'].includes(socketIp)
? (requestIp || normalizeIp(forwarded) || socketIp)
: socketIp;
}
function isRequestIpAllowed(requestIp, allowedIps = []) {
const normalizedRequestIp = normalizeIp(requestIp);
const normalizedAllowedIps = allowedIps.map(normalizeIp).filter(Boolean);
return normalizedAllowedIps.length === 0 || normalizedAllowedIps.includes(normalizedRequestIp);
}
function hashBody(rawBody) {
return crypto.createHash('sha256').update(String(rawBody || ''), 'utf8').digest('hex');
}
function buildCanonicalRequest({ timestamp, nonce, method, originalUrl, rawBody }) {
return [
String(timestamp || '').trim(),
String(nonce || '').trim(),
String(method || 'GET').toUpperCase(),
String(originalUrl || ''),
hashBody(rawBody),
].join('\n');
}
function signCanonicalRequest(apiKey, canonicalRequest) {
return crypto
.createHmac('sha256', String(apiKey || ''))
.update(String(canonicalRequest || ''), 'utf8')
.digest('hex');
}
function safeEqual(left, right) {
const leftBuffer = Buffer.from(String(left || ''), 'utf8');
const rightBuffer = Buffer.from(String(right || ''), 'utf8');
return leftBuffer.length === rightBuffer.length
&& crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function cleanupExpiredNonces(nowMs) {
for (const [key, expiresAt] of usedNonces.entries()) {
if (expiresAt <= nowMs) usedNonces.delete(key);
}
}
function verifyHmacRequest(req, apiKey, keyId, options = {}) {
const timestamp = String(req.get('x-timestamp') || '').trim();
const nonce = String(req.get('x-nonce') || '').trim();
const providedSignature = String(req.get('x-signature') || '')
.trim()
.replace(/^sha256=/i, '')
.toLowerCase();
if (!timestamp || !nonce || !providedSignature) {
return { valid: false, reason: '缺少 X-Timestamp、X-Nonce 或 X-Signature' };
}
const timestampSeconds = Number(timestamp);
const nowMs = Number(options.nowMs || Date.now());
const toleranceSeconds = Number(options.toleranceSeconds || SIGNATURE_TOLERANCE_SECONDS);
if (!Number.isFinite(timestampSeconds)) {
return { valid: false, reason: 'X-Timestamp 必须是 Unix 秒级时间戳' };
}
if (Math.abs(Math.floor(nowMs / 1000) - timestampSeconds) > toleranceSeconds) {
return { valid: false, reason: '请求时间戳已过期' };
}
if (!/^[A-Za-z0-9_-]{8,160}$/.test(nonce)) {
return { valid: false, reason: 'X-Nonce 格式不正确' };
}
const rawBody = typeof req.rawBody === 'string'
? req.rawBody
: (req.body && Object.keys(req.body).length > 0 ? JSON.stringify(req.body) : '');
const canonicalRequest = buildCanonicalRequest({
timestamp,
nonce,
method: req.method,
originalUrl: req.originalUrl,
rawBody,
});
const expectedSignature = signCanonicalRequest(apiKey, canonicalRequest);
if (!safeEqual(providedSignature, expectedSignature)) {
return { valid: false, reason: 'HMAC 签名不匹配' };
}
cleanupExpiredNonces(nowMs);
const nonceKey = `${keyId || 'key'}:${nonce}`;
if (usedNonces.has(nonceKey)) {
return { valid: false, reason: 'X-Nonce 已使用,请勿重放请求' };
}
usedNonces.set(nonceKey, nowMs + toleranceSeconds * 1000);
return { valid: true, canonicalRequest };
}
module.exports = {
buildCanonicalRequest,
hashBody,
isRequestIpAllowed,
normalizeIp,
resolveRequestIp,
signCanonicalRequest,
verifyHmacRequest,
};