etherpad-lite/src/node/utils/anonymizeIp.ts
John McLear 6195289198
feat(gdpr): IP/privacy audit (PR2 of #6701) (#7547)
* docs: PR2 GDPR IP/privacy audit design spec

Second of five GDPR PRs (#6701). Audit identifies four log-sites that
leak IPs despite disableIPlogging=true, proposes a tri-state ipLogging
setting with a back-compat shim, and specifies a doc/privacy.md that
documents Etherpad's actual IP handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: PR2 GDPR IP/privacy audit implementation plan

7 TDD-structured tasks: anonymizeIp helper + unit tests, tri-state
ipLogging setting with disableIPlogging deprecation shim, wiring
through 5 leaking log sites, clientVars.clientIp removal, access-log
integration test, doc/privacy.md, and PR handoff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gdpr): anonymizeIp helper with v4/v6/v4-mapped truncation

* feat(gdpr): tri-state ipLogging setting + disableIPlogging shim

* fix(gdpr): route every IP log site through anonymizeIp

Closes four leaks where disableIPlogging was silently ignored
(rate-limit warn, both auth-log calls in webaccess, import/export
rate-limit warn) and normalises the four that did honour the flag
onto the new ipLogging tri-state via the shared helper.

* chore(gdpr): drop dead clientVars.clientIp placeholder

Server side: remove the literal '127.0.0.1' assignments from both
clientVars and collab_client_vars. Type side: drop clientIp from
ClientVarPayload and ServerVar. pad.getClientIp now returns the same
'127.0.0.1' literal as a plugin-compat shim (pad_utils.uniqueId still
uses it as a prefix).

* test(gdpr): ipLogging modes + disableIPlogging shim

* docs(gdpr): operator-facing privacy and IP handling statement

* fix(gdpr): validate ipLogging at load + regression test for log sites

Qodo review:
- settings.ipLogging is loaded as a trusted union but nothing enforced
  the shape. An unknown value (e.g. a typo or null) silently fell
  through to anonymizeIp's "truncated" branch and emitted partially
  redacted IPs. Fall back to "anonymous" with a WARN at load time.
- New regression test scans the four known log-sites for raw
  req.ip / socket.request.ip / request.ip inside logger calls that
  don't wrap through anonymizeIp / logIp, so a future edit that
  re-introduces a raw IP fails CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:47:40 +01:00

35 lines
1.4 KiB
TypeScript

'use strict';
import {isIP} from 'node:net';
export type IpLogging = 'full' | 'truncated' | 'anonymous';
const IPV4_MAPPED = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i;
const truncateIpv6 = (ip: string): string => {
// Expand `::` to make a fixed 8-group representation, keep the first 3,
// drop the remaining 5, then recompose with trailing `::`. Collapse trailing
// zero groups in the kept prefix so `2001:db8:0::` becomes `2001:db8::`.
const [head, tail] = ip.split('::');
const headParts = head === '' ? [] : head.split(':');
const tailParts = tail == null ? [] : tail === '' ? [] : tail.split(':');
const missing = 8 - headParts.length - tailParts.length;
const full = [...headParts, ...Array(Math.max(0, missing)).fill('0'), ...tailParts];
const keep = full.slice(0, 3).map((g) => g.toLowerCase().replace(/^0+(?=.)/, ''));
while (keep.length > 0 && keep[keep.length - 1] === '0') keep.pop();
return `${keep.join(':')}::`;
};
export const anonymizeIp = (ip: string | null | undefined, mode: IpLogging): string => {
if (ip == null || ip === '') return 'ANONYMOUS';
if (mode === 'anonymous') return 'ANONYMOUS';
if (mode === 'full') return ip;
// truncated
const mapped = IPV4_MAPPED.exec(ip);
if (mapped != null) return `::ffff:${mapped[1].replace(/\.\d+$/, '.0')}`;
switch (isIP(ip)) {
case 4: return ip.replace(/\.\d+$/, '.0');
case 6: return truncateIpv6(ip);
default: return 'ANONYMOUS';
}
};