This commit is contained in:
2026-09-28 23:24:50 +02:00
parent 2cc768f1fd
commit 73c0659d4a
29 changed files with 820 additions and 117 deletions
+10 -2
View File
@@ -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);
};
+6 -2
View File
@@ -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,
+13 -4
View File
@@ -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 -1
View File
@@ -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),
+2 -2
View File
@@ -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
View File
@@ -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
`;
+2
View File
@@ -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;