191 lines
6.8 KiB
JavaScript
191 lines
6.8 KiB
JavaScript
import crypto from "crypto";
|
|
import db from "./sql.mjs";
|
|
import cfg from "./config.mjs";
|
|
|
|
const RATE_LIMIT_WINDOW_MINUTES = 600; // 10 hours
|
|
const MAX_ATTEMPTS = 5;
|
|
|
|
export default new class {
|
|
/**
|
|
* Anonymize IP address using Hmac-SHA256 with a secret salt.
|
|
* @param {string} ip
|
|
* @returns {string}
|
|
*/
|
|
hashIP(ip) {
|
|
if (!ip) return "unknown";
|
|
const secret = cfg.main.invite_secret;
|
|
if (!secret) {
|
|
throw new Error('[FATAL] invite_secret is not configured. Set it in config.json to enable IP hashing. Refusing to use a predictable fallback salt.');
|
|
}
|
|
return crypto.createHmac("sha256", secret).update(ip).digest("hex");
|
|
}
|
|
|
|
/**
|
|
* Get real IP from request headers or socket.
|
|
* @param {object} req
|
|
* @returns {string}
|
|
*/
|
|
getRealIP(req) {
|
|
let ip = req.headers['cf-connecting-ip'] ||
|
|
req.headers['true-client-ip'] ||
|
|
req.headers['x-client-ip'] ||
|
|
req.headers['x-real-ip'] ||
|
|
(req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0].trim() : null) ||
|
|
req.socket.remoteAddress;
|
|
|
|
if (!ip) return "unknown";
|
|
|
|
// Handle IPv6 loopback and mapped IPv4
|
|
if (ip === "::1") ip = "127.0.0.1";
|
|
if (ip && ip.startsWith("::ffff:")) ip = ip.substring(7);
|
|
|
|
// Basic IPv6 normalization (ensure consistent case and representation if possible)
|
|
if (ip && ip.includes(":")) ip = ip.toLowerCase();
|
|
|
|
if (cfg.main.development && ip === "127.0.0.1" && req.headers) {
|
|
console.debug('[SECURITY] Local IP detected. Headers:', req.headers);
|
|
}
|
|
|
|
return ip;
|
|
}
|
|
|
|
/**
|
|
* Record an attempt in the database.
|
|
* @param {string} ip
|
|
* @param {string} username
|
|
* @param {string} type 'login' | 'register'
|
|
* @param {boolean} success
|
|
*/
|
|
async recordAttempt(ip, username, type, success) {
|
|
const ip_hash = this.hashIP(ip);
|
|
|
|
await db`
|
|
insert into login_attempts (ip_hash, username, type, success)
|
|
values (${ip_hash}, ${username?.toLowerCase() || null}, ${type}, ${success})
|
|
`.catch(err => console.error(`[SECURITY] Failed to record ${type} attempt:`, err));
|
|
|
|
if (!success) {
|
|
let windowMinutes = RATE_LIMIT_WINDOW_MINUTES;
|
|
let maxAttempts = MAX_ATTEMPTS;
|
|
let onlyFailures = true;
|
|
|
|
if (type === 'password_reset_request') {
|
|
windowMinutes = 1440;
|
|
maxAttempts = 1;
|
|
onlyFailures = false;
|
|
} else if (type === 'password_reset_execution') {
|
|
windowMinutes = 60;
|
|
maxAttempts = 5;
|
|
onlyFailures = false;
|
|
}
|
|
|
|
const windowStart = new Date(Date.now() - windowMinutes * 60000);
|
|
|
|
const ipAttempts = await db`
|
|
select count(*) as count
|
|
from login_attempts
|
|
where ip_hash = ${ip_hash}
|
|
and type = ${type}
|
|
${onlyFailures ? db`and success = false` : db``}
|
|
and attempted_at > ${windowStart}
|
|
`.catch(() => [{ count: 0 }]);
|
|
|
|
const count = +ipAttempts[0].count;
|
|
const isBanned = count >= maxAttempts;
|
|
|
|
console.warn(`[SECURITY] Failed ${type} attempt: user=${username}, ip_hash=${ip_hash}, ip_banned=${isBanned} (${count}/${maxAttempts})`);
|
|
} else if (cfg.main.development) {
|
|
console.log(`[SECURITY] Recording ${type} attempt: user=${username}, success=${success}, ip_hash=${ip_hash}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear failed attempts for a given IP and/or username.
|
|
* @param {string} ip
|
|
* @param {string} username
|
|
*/
|
|
async clearAttempts(ip, username) {
|
|
const ip_hash = this.hashIP(ip);
|
|
if (cfg.main.development) console.log(`[SECURITY] Clearing attempts for user=${username}, ip_hash=${ip_hash}`);
|
|
await db`
|
|
delete from login_attempts
|
|
where (ip_hash = ${ip_hash} OR username = ${username?.toLowerCase() || ''})
|
|
`.catch(err => console.error(`[SECURITY] Failed to clear attempts:`, err));
|
|
}
|
|
|
|
async isRateLimited(ip, username, type) {
|
|
const ip_hash = this.hashIP(ip);
|
|
|
|
let windowMinutes = RATE_LIMIT_WINDOW_MINUTES;
|
|
let maxAttempts = MAX_ATTEMPTS;
|
|
let onlyFailures = true;
|
|
|
|
if (type === 'password_reset_request') {
|
|
windowMinutes = 1440; // 24 hours
|
|
maxAttempts = 1;
|
|
onlyFailures = false; // Count all attempts to prevent spam
|
|
} else if (type === 'password_reset_execution') {
|
|
windowMinutes = 60; // 1 hour
|
|
maxAttempts = 5;
|
|
onlyFailures = false; // Count all efforts
|
|
}
|
|
|
|
const windowStart = new Date(Date.now() - windowMinutes * 60000);
|
|
|
|
if (cfg.main.development) console.log(`[SECURITY] Checking rate limit for ${type}: user=${username}, ip_hash=${ip_hash}`);
|
|
|
|
// Check attempts by IP
|
|
const ipAttempts = await db`
|
|
select count(*) as count
|
|
from login_attempts
|
|
where ip_hash = ${ip_hash}
|
|
and type = ${type}
|
|
${onlyFailures ? db`and success = false` : db``}
|
|
and attempted_at > ${windowStart}
|
|
`;
|
|
|
|
const ipCount = +ipAttempts[0].count;
|
|
if (ipCount >= maxAttempts) {
|
|
console.warn(`[SECURITY] Rate limit hit for ${type}: ip_hash=${ip_hash}, attempts=${ipCount}/${maxAttempts}`);
|
|
return true;
|
|
}
|
|
|
|
// Check attempts by username (if provided)
|
|
if (username) {
|
|
const userAttempts = await db`
|
|
select count(*) as count
|
|
from login_attempts
|
|
where username = ${username.toLowerCase()}
|
|
and type = ${type}
|
|
${onlyFailures ? db`and success = false` : db``}
|
|
and attempted_at > ${windowStart}
|
|
`;
|
|
const userCount = +userAttempts[0].count;
|
|
if (userCount >= maxAttempts) {
|
|
console.warn(`[SECURITY] Rate limit hit for ${type}: user=${username}, attempts=${userCount}/${maxAttempts}`);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Log user IP for historical tracking if enabled.
|
|
* @param {number} userId
|
|
* @param {string} ip
|
|
*/
|
|
async logUserIP(userId, ip) {
|
|
if (!cfg.websrv.log_user_ips || !userId || !ip) return;
|
|
|
|
const { getHashUserIps } = await import("./settings.mjs");
|
|
const finalIp = getHashUserIps() ? this.hashIP(ip) : ip;
|
|
|
|
await db`
|
|
insert into user_ips (user_id, ip)
|
|
values (${userId}, ${finalIp})
|
|
on conflict (user_id, ip) do update set last_seen = now()
|
|
`.catch(err => console.error(`[SECURITY] Failed to log user IP:`, err));
|
|
}
|
|
};
|