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>
This commit is contained in:
John McLear 2026-05-01 20:47:40 +08:00 committed by GitHub
parent e39dbde887
commit 6195289198
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1308 additions and 17 deletions

View file

@ -34,6 +34,8 @@ import settings, {
exportAvailable,
sofficeAvailable
} from '../utils/Settings';
import {anonymizeIp} from '../utils/anonymizeIp';
const logIp = (ip: string | null | undefined) => anonymizeIp(ip, settings.ipLogging);
const securityManager = require('../db/SecurityManager');
const plugins = require('../../static/js/pluginfw/plugin_defs');
import log4js from 'log4js';
@ -231,7 +233,7 @@ exports.handleDisconnect = async (socket:any) => {
accessLogger.info('[LEAVE]' +
` pad:${session.padId}` +
` socket:${socket.id}` +
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
` IP:${logIp(socket.request.ip)}` +
` authorID:${session.author}` +
(user && user.username ? ` username:${user.username}` : ''));
/* eslint-enable prefer-template */
@ -339,7 +341,7 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
try {
await rateLimiter.consume(socket.request.ip); // consume 1 point per event from IP
} catch (err) {
messageLogger.warn(`Rate limited IP ${socket.request.ip}. To reduce the amount of rate ` +
messageLogger.warn(`Rate limited IP ${logIp(socket.request.ip)}. To reduce the amount of rate ` +
'limiting that happens edit the rateLimit values in settings.json');
stats.meter('rateLimited').mark();
socket.emit('message', {disconnect: 'rateLimited'});
@ -384,7 +386,7 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
const auth = thisSession.auth;
if (!auth) {
const ip = settings.disableIPlogging ? 'ANONYMOUS' : (socket.request.ip || '<unknown>');
const ip = logIp(socket.request.ip);
const msg = JSON.stringify(message, null, 2);
throw new Error(`pre-CLIENT_READY message from IP ${ip}: ${msg}`);
}
@ -401,7 +403,7 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
throw new Error([
'Author ID changed mid-session. Bad or missing token or sessionID?',
`socket:${socket.id}`,
`IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}`,
`IP:${logIp(socket.request.ip)}`,
`originalAuthorID:${thisSession.author}`,
`newAuthorID:${authorID}`,
...(user && user.username) ? [`username:${user.username}`] : [],
@ -1006,7 +1008,7 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
accessLogger.info(`[${pad.head > 0 ? 'ENTER' : 'CREATE'}]` +
` pad:${sessionInfo.padId}` +
` socket:${socket.id}` +
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
` IP:${logIp(socket.request.ip)}` +
` authorID:${sessionInfo.author}` +
(user && user.username ? ` username:${user.username}` : ''));
/* eslint-enable prefer-template */
@ -1116,7 +1118,6 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
savedRevisions: pad.getSavedRevisions(),
collab_client_vars: {
initialAttributedText: atext,
clientIp: '127.0.0.1',
padId: sessionInfo.auth.padID,
historicalAuthorData,
apool,
@ -1124,7 +1125,6 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
time: currentTime,
},
colorPalette: authorManager.getColorPalette(),
clientIp: '127.0.0.1',
userColor: authorColorId,
padId: sessionInfo.auth.padID,
padOptions: settings.padOptions,