Files
f0ckm/src/inc/security.mjs

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));
}
};