hgfhfd
This commit is contained in:
+10
-2
@@ -1,5 +1,6 @@
|
||||
import db from "./sql.mjs";
|
||||
import cfg from "./config.mjs";
|
||||
import { getHwFingerprintEnabled } from "./settings.mjs";
|
||||
|
||||
/**
|
||||
* retention.mjs — automatic deletion of personal data after a configurable period.
|
||||
@@ -12,6 +13,9 @@ import cfg from "./config.mjs";
|
||||
* retention_sessions_days sessions unused for this long are deleted (logs that device out)
|
||||
* retention_fingerprint_days hardware fingerprint is cleared from anonymous identities inactive this long
|
||||
*
|
||||
* With websrv.anon_hw_fingerprint: false, every stored device fingerprint (identities and activity log) is
|
||||
* cleared on each run, regardless of age.
|
||||
*
|
||||
* Active bans (banned_ips / banned_fingerprints / banned_hardware_fingerprints) are not touched: they are kept
|
||||
* until they expire or are lifted.
|
||||
*/
|
||||
@@ -80,7 +84,11 @@ export const runRetention = async () => {
|
||||
await step('user_sessions', () => db`delete from user_sessions where last_used < ${nowSecs - r.sessions * 86400}`);
|
||||
}
|
||||
|
||||
if (r.fingerprint) {
|
||||
if (!getHwFingerprintEnabled()) {
|
||||
// Fingerprinting switched off: don't keep any fingerprints collected while it was on
|
||||
await step('anon.hw_fingerprint', () => db`update anon_identities set hw_fingerprint = null where hw_fingerprint is not null`);
|
||||
await step('activity.hw_fingerprint', () => db`update anon_activity_log set hw_fingerprint = null where hw_fingerprint is not null`);
|
||||
} else if (r.fingerprint) {
|
||||
await step('anon.hw_fingerprint', () => db`update anon_identities set hw_fingerprint = null where hw_fingerprint is not null and last_seen < ${before(r.fingerprint)}`);
|
||||
}
|
||||
|
||||
@@ -91,7 +99,7 @@ export const runRetention = async () => {
|
||||
export const startRetention = () => {
|
||||
const r = getRetention();
|
||||
const fmt = (d) => d ? `${d}d` : 'forever';
|
||||
console.log(`[BOOT] Data retention: IPs ${fmt(r.ip)}, activity log ${fmt(r.activity_log)}, login attempts ${fmt(r.login_attempts)}, sessions ${fmt(r.sessions)}, device fingerprints ${fmt(r.fingerprint)}`);
|
||||
console.log(`[BOOT] Data retention: IPs ${fmt(r.ip)}, activity log ${fmt(r.activity_log)}, login attempts ${fmt(r.login_attempts)}, sessions ${fmt(r.sessions)}, device fingerprints ${getHwFingerprintEnabled() ? fmt(r.fingerprint) : 'disabled (purged)'}`);
|
||||
setTimeout(runRetention, 30_000);
|
||||
setInterval(runRetention, RUN_INTERVAL_MS);
|
||||
};
|
||||
|
||||
@@ -467,11 +467,15 @@ export default (router, tpl) => {
|
||||
if (!ip) throw new Error('Missing IP address');
|
||||
|
||||
const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000);
|
||||
const ipHash = security.hashIP(ip);
|
||||
// Already a stored hash (banned from the IP list) or a raw address typed by the moderator
|
||||
const isHash = /^[a-f0-9]{64}$/i.test(ip);
|
||||
const ipHash = isHash ? ip.toLowerCase() : security.hashIP(ip);
|
||||
// With hashing on, the raw address is never persisted, not even in the ban list
|
||||
const ipStored = (isHash || cfg.websrv.hash_user_ips) ? ipHash : ip;
|
||||
|
||||
await db`
|
||||
INSERT INTO banned_ips (ip, ip_hash, banned_by, reason, expires_at)
|
||||
VALUES (${ip}, ${ipHash}, ${req.session.id}, ${reason || 'Banned by moderator'}, ${expires})
|
||||
VALUES (${ipStored}, ${ipHash}, ${req.session.id}, ${reason || 'Banned by moderator'}, ${expires})
|
||||
ON CONFLICT (ip) DO UPDATE
|
||||
SET reason = EXCLUDED.reason,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
buildRegistrationOptions, buildAuthenticationOptions,
|
||||
base64url, fromBase64url, getRpIdFromHost
|
||||
} from '../../webauthn.mjs';
|
||||
import { getEnableAnonymousAccess } from '../../settings.mjs';
|
||||
import { getEnableAnonymousAccess, getHwFingerprintEnabled } from '../../settings.mjs';
|
||||
|
||||
// Maximum number of passkeys a single anonymous identity may hold
|
||||
const MAX_ANON_PASSKEYS = 4;
|
||||
@@ -38,6 +38,10 @@ export default router => {
|
||||
return `${prefix} (${clean || 'Violation of community rules'})`;
|
||||
};
|
||||
|
||||
// Client-supplied device fingerprint, or null when fingerprinting is disabled (then nothing is stored or matched)
|
||||
const acceptHw = (hw) => (getHwFingerprintEnabled() && hw) ? String(hw).slice(0, 128) : null;
|
||||
const acceptTombstone = (t) => (t && !getHwFingerprintEnabled()) ? { ...t, hw_fingerprint: null } : t;
|
||||
|
||||
const setBanCookie = (res, reason, expires) => {
|
||||
const payload = encodeURIComponent(JSON.stringify({
|
||||
banned: true,
|
||||
@@ -193,7 +197,9 @@ export default router => {
|
||||
}
|
||||
|
||||
const body = req.post || req.body || {};
|
||||
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body;
|
||||
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: rawHw, tombstone: rawTombstone } = body;
|
||||
const hwFingerprint = acceptHw(rawHw);
|
||||
const tombstone = acceptTombstone(rawTombstone);
|
||||
|
||||
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
|
||||
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
|
||||
@@ -326,7 +332,9 @@ export default router => {
|
||||
}
|
||||
|
||||
const body = req.post || req.body || {};
|
||||
const { challenge, clientDataJSON, authenticatorData, signature, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body;
|
||||
const { challenge, clientDataJSON, authenticatorData, signature, credentialId, hw_fingerprint: rawHw, tombstone: rawTombstone } = body;
|
||||
const hwFingerprint = acceptHw(rawHw);
|
||||
const tombstone = acceptTombstone(rawTombstone);
|
||||
|
||||
if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) {
|
||||
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
|
||||
@@ -502,7 +510,8 @@ export default router => {
|
||||
}
|
||||
|
||||
const body = req.post || req.body || {};
|
||||
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint } = body;
|
||||
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: rawHw } = body;
|
||||
const hwFingerprint = acceptHw(rawHw);
|
||||
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
|
||||
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import lib from "../lib.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import { createI18n } from "../i18n.mjs";
|
||||
import { render502 } from "../private_items.mjs";
|
||||
import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession, getSessionOwnerName, isOnaraEnabledFor, getHashUserIps, getLogUserIps, getEnableAnonymousAccess } from "../settings.mjs";
|
||||
import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession, getSessionOwnerName, isOnaraEnabledFor, getHashUserIps, getLogUserIps, getEnableAnonymousAccess, getHwFingerprintEnabled } from "../settings.mjs";
|
||||
import { getRetention } from "../retention.mjs";
|
||||
import { getIpHashSource } from "../security.mjs";
|
||||
|
||||
const auth = async (req, res, next) => {
|
||||
if (!req.session)
|
||||
@@ -701,7 +702,9 @@ export default (router, tpl) => {
|
||||
hash_ips: hashIps,
|
||||
ip_mode: !logIps ? 'off' : (hashIps ? 'hashed' : 'raw'),
|
||||
anon: getEnableAnonymousAccess(),
|
||||
hw: getEnableAnonymousAccess() && getHwFingerprintEnabled(),
|
||||
https: String(cfg.main?.url?.full || '').startsWith('https'),
|
||||
ip_secret_env: getIpHashSource().startsWith('env'),
|
||||
domain: cfg.main?.url?.domain || '',
|
||||
ret: {
|
||||
ip: period(r.ip),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import db from "../sql.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getEnableItemSlugs, isAnonymizeSession } from "../settings.mjs";
|
||||
import { getEnableItemSlugs, isAnonymizeSession, getHwFingerprintEnabled } from "../settings.mjs";
|
||||
import { setMotd } from "../motd.mjs";
|
||||
import security from "../security.mjs";
|
||||
|
||||
@@ -723,7 +723,7 @@ export default (router, tpl) => {
|
||||
const clientIp = security.getRealIP(req);
|
||||
const clientIpHash = security.hashIP(clientIp);
|
||||
const clientFp = req.session?.fingerprint || req.session?.anon_fingerprint || req.url.qs?.fp || null;
|
||||
const clientHw = req.session?.hw_fingerprint || req.url.qs?.hw || null;
|
||||
const clientHw = getHwFingerprintEnabled() ? (req.session?.hw_fingerprint || req.url.qs?.hw || null) : null;
|
||||
const clientUserId = (req.session && typeof req.session === 'object') ? req.session.id : null;
|
||||
|
||||
const client = {
|
||||
|
||||
+36
-6
@@ -5,21 +5,51 @@ import cfg from "./config.mjs";
|
||||
const RATE_LIMIT_WINDOW_MINUTES = 600; // 10 hours
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* IP hash secrets.
|
||||
* current: IP_HASH_SECRET env var > main.ip_hash_secret > main.invite_secret (legacy fallback).
|
||||
* Kept out of config.json / DB dumps when set via the environment, so a leaked dump can't be reversed.
|
||||
* legacy: secrets used before a rotation. Only used to *match* old hashes (bans), never to write new ones.
|
||||
* main.ip_hash_legacy_secrets (array); the invite_secret is added automatically when a dedicated
|
||||
* secret is set, so bans hashed with it keep working until they expire.
|
||||
*/
|
||||
const ipHashSecrets = (() => {
|
||||
const env = process.env.IP_HASH_SECRET || '';
|
||||
const dedicated = env || cfg.main.ip_hash_secret || '';
|
||||
const current = dedicated || cfg.main.invite_secret || '';
|
||||
const legacy = new Set([].concat(cfg.main.ip_hash_legacy_secrets || []).filter(Boolean));
|
||||
if (dedicated && cfg.main.invite_secret) legacy.add(cfg.main.invite_secret);
|
||||
legacy.delete(current);
|
||||
const source = env ? 'env IP_HASH_SECRET' : (cfg.main.ip_hash_secret ? 'config main.ip_hash_secret' : 'config main.invite_secret (shared, set IP_HASH_SECRET)');
|
||||
return { current, legacy: [...legacy], source };
|
||||
})();
|
||||
|
||||
export const getIpHashSource = () => ipHashSecrets.source;
|
||||
|
||||
export default new class {
|
||||
/**
|
||||
* Anonymize IP address using Hmac-SHA256 with a secret salt.
|
||||
* Anonymize IP address using Hmac-SHA256 with the current IP hash secret.
|
||||
* @param {string} ip
|
||||
* @returns {string}
|
||||
*/
|
||||
hashIP(ip) {
|
||||
hashIP(ip, secret = ipHashSecrets.current) {
|
||||
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.');
|
||||
throw new Error('[FATAL] No IP hash secret configured. Set IP_HASH_SECRET (env) or main.ip_hash_secret. Refusing to use a predictable fallback salt.');
|
||||
}
|
||||
return crypto.createHmac("sha256", secret).update(ip).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes of an IP under the current and all legacy secrets (for matching data written before a rotation).
|
||||
* @param {string} ip
|
||||
* @returns {string[]}
|
||||
*/
|
||||
hashIPCandidates(ip) {
|
||||
if (!ip) return [];
|
||||
return [ipHashSecrets.current, ...ipHashSecrets.legacy].filter(Boolean).map(sec => this.hashIP(ip, sec));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real IP from request headers or socket.
|
||||
* @param {object} req
|
||||
@@ -209,11 +239,11 @@ export default new class {
|
||||
if (!ip || ip === "unknown") return null;
|
||||
if (cfg.main.development && ip === "127.0.0.1" && !cfg.test_ban_localhost) return null;
|
||||
try {
|
||||
const ipHash = this.hashIP(ip);
|
||||
const hashes = this.hashIPCandidates(ip);
|
||||
const rows = await db`
|
||||
select id, ip, ip_hash, reason, expires_at as expires
|
||||
from banned_ips
|
||||
where (ip = ${ip} or ip = ${ipHash} or ip_hash = ${ipHash} or ip_hash = ${ip})
|
||||
where (ip = ${ip} or ip = any(${hashes}) or ip_hash = any(${hashes}) or ip_hash = ${ip})
|
||||
and (expires_at is null or expires_at > now())
|
||||
limit 1
|
||||
`;
|
||||
|
||||
@@ -263,6 +263,8 @@ export const getLogUserIps = () => !!cfg.websrv.log_user_ips;
|
||||
export const setLogUserIps = (val) => {}; // No-op, strictly config-based
|
||||
|
||||
export const getHashUserIps = () => !!cfg.websrv.hash_user_ips;
|
||||
// Device (hardware) fingerprinting of anonymous users for ban enforcement. On unless explicitly disabled.
|
||||
export const getHwFingerprintEnabled = () => cfg.websrv.anon_hw_fingerprint !== false;
|
||||
export const setHashUserIps = (val) => {}; // No-op, strictly config-based
|
||||
|
||||
export const getAllowCommentDeletion = () => !!cfg.websrv.allow_comment_deletion;
|
||||
|
||||
Reference in New Issue
Block a user