docs/superpowers/plans/2026-04-19-gdpr-pr2-ip-privacy-audit.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix four existing leaks where disableIPlogging is silently ignored, replace the boolean with a tri-state ipLogging: 'full' | 'truncated' | 'anonymous' setting (with a back-compat deprecation shim), drop the dead-weight clientVars.clientIp placeholder, and ship doc/privacy.md documenting Etherpad's real IP behaviour.
Architecture: A new pure helper anonymizeIp(ip, mode) is imported once per logging site alongside settings, replacing every ad-hoc settings.disableIPlogging ? 'ANONYMOUS' : ip ternary. Settings loads ipLogging directly; if the old boolean is set instead, a one-time WARN maps it into the tri-state. clientVars.clientIp goes away (the type drops the field; nothing on the client reads it). Tests cover the helper and an end-to-end access-log assertion per mode.
Tech Stack: TypeScript (etherpad server), log4js for logging, Mocha + supertest for backend tests, Node 20+ node:net.isIP.
Created by this plan:
src/node/utils/anonymizeIp.ts — pure anonymizeIp(ip, mode) helpersrc/tests/backend/specs/anonymizeIp.ts — unit tests for the helpersrc/tests/backend/specs/ipLoggingSetting.ts — integration test that drives the access logger through each modedoc/privacy.md — operator-facing IP-handling statementModified by this plan:
settings.json.template, settings.json.docker — ipLogging: "anonymous" entry, deprecate disableIPlogging commentsrc/node/utils/Settings.ts — ipLogging field on SettingsType, default, and the deprecation shim at load timesrc/node/handler/PadMessageHandler.ts — replace 4 ternaries with anonymizeIp(), drop dead clientIp: '127.0.0.1' literalssrc/node/handler/SocketIORouter.ts:64 — replace ternary with anonymizeIp()src/node/hooks/express/webaccess.ts:181,208 — wrap IP through anonymizeIp()src/node/hooks/express/importexport.ts:22 — wrap IP through anonymizeIp()src/static/js/types/SocketIOMessage.ts — remove clientIp: string from ClientVarPayloaddoc/settings.md — cross-link to the new privacy doc at the disableIPlogging entryanonymizeIp() helper + unit testsFiles:
Create: src/node/utils/anonymizeIp.ts
Create: src/tests/backend/specs/anonymizeIp.ts
Step 1: Write the failing unit test
// src/tests/backend/specs/anonymizeIp.ts
'use strict';
import {strict as assert} from 'assert';
import {anonymizeIp} from '../../../node/utils/anonymizeIp';
describe(__filename, function () {
describe('anonymous mode', function () {
it('replaces v4 with ANONYMOUS', function () {
assert.equal(anonymizeIp('1.2.3.4', 'anonymous'), 'ANONYMOUS');
});
it('replaces v6 with ANONYMOUS', function () {
assert.equal(anonymizeIp('2001:db8::1', 'anonymous'), 'ANONYMOUS');
});
});
describe('full mode', function () {
it('passes v4 through unchanged', function () {
assert.equal(anonymizeIp('1.2.3.4', 'full'), '1.2.3.4');
});
it('passes v6 through unchanged', function () {
assert.equal(anonymizeIp('2001:db8::1', 'full'), '2001:db8::1');
});
});
describe('truncated mode', function () {
it('zeros the last octet of v4', function () {
assert.equal(anonymizeIp('1.2.3.4', 'truncated'), '1.2.3.0');
});
it('keeps the first /48 of a compressed v6', function () {
assert.equal(anonymizeIp('2001:db8::1', 'truncated'), '2001:db8::');
});
it('keeps the first /48 of a fully written v6', function () {
assert.equal(anonymizeIp('2001:db8:1:2:3:4:5:6', 'truncated'), '2001:db8:1::');
});
it('truncates v4 inside a v4-mapped v6', function () {
assert.equal(anonymizeIp('::ffff:1.2.3.4', 'truncated'), '::ffff:1.2.3.0');
});
it('returns ANONYMOUS for a non-IP string', function () {
assert.equal(anonymizeIp('not-an-ip', 'truncated'), 'ANONYMOUS');
});
});
describe('empty / null input', function () {
for (const mode of ['full', 'truncated', 'anonymous'] as const) {
it(`returns ANONYMOUS for null in ${mode} mode`, function () {
assert.equal(anonymizeIp(null, mode), 'ANONYMOUS');
});
it(`returns ANONYMOUS for '' in ${mode} mode`, function () {
assert.equal(anonymizeIp('', mode), 'ANONYMOUS');
});
}
});
});
Run: pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/anonymizeIp.ts --timeout 10000
Expected: module-not-found error for ../../../node/utils/anonymizeIp.
// src/node/utils/anonymizeIp.ts
'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 `::`.
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+(?=.)/, ''));
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';
}
};
Run: pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/anonymizeIp.ts --timeout 10000
Expected: all 14 assertions pass.
git add src/node/utils/anonymizeIp.ts src/tests/backend/specs/anonymizeIp.ts
git commit -m "feat(gdpr): anonymizeIp helper with v4/v6/v4-mapped truncation"
ipLogging setting + deprecation shimFiles:
Modify: src/node/utils/Settings.ts:243-245, 499-501, 955-975
Modify: settings.json.template (near existing disableIPlogging block)
Modify: settings.json.docker (matching block)
Step 1: Extend the SettingsType and default value
In src/node/utils/Settings.ts, add ipLogging next to disableIPlogging:
// around line 245
logLayoutType: string,
disableIPlogging: boolean, // deprecated — see ipLogging
ipLogging: 'full' | 'truncated' | 'anonymous',
automaticReconnectionTimeout: number,
And in the settings object default (around line 501):
disableIPlogging: false,
ipLogging: 'anonymous',
In Settings.ts, locate the storeSettings(...) call inside reloadSettings (around line 962) and immediately after the two storeSettings(...) calls, insert:
// Deprecation shim: if the operator set the legacy boolean `disableIPlogging`
// without also setting the new tri-state `ipLogging`, map the boolean over
// once and emit a WARN. An explicitly-set `ipLogging` always wins.
if (settingsParsed != null && 'disableIPlogging' in (settingsParsed as any) &&
!('ipLogging' in (settingsParsed as any))) {
logger.warn(
'`disableIPlogging` is deprecated; use `ipLogging: "anonymous"` (or ' +
'"truncated" / "full") instead.');
settings.ipLogging = (settingsParsed as any).disableIPlogging ? 'anonymous' : 'full';
}
(logger is already declared higher in Settings.ts; no extra import.)
ipLogging to settings.json.templateFind the disableIPlogging block in settings.json.template and replace it with:
/*
* Controls what Etherpad writes to its logs about client IP addresses.
*
* "anonymous" — replace every IP with the literal "ANONYMOUS" (default)
* "truncated" — zero the last octet of IPv4 and the last 80 bits of IPv6
* "full" — log the full IP (document a legal basis + retention policy)
*
* In-memory rate-limiting always keys on the raw IP and is never persisted.
*/
"ipLogging": "anonymous",
/*
* Deprecated — use ipLogging above instead. Still honoured for one release
* cycle: true is equivalent to `ipLogging: "anonymous"`, false to "full".
*/
"disableIPlogging": false,
settings.json.dockerApply the same edit to settings.json.docker, using the same env-variable style used for its other entries:
"ipLogging": "${IP_LOGGING:anonymous}",
"disableIPlogging": "${DISABLE_IP_LOGGING:false}",
Run: pnpm --filter ep_etherpad-lite run ts-check
Expected: exit 0.
git add src/node/utils/Settings.ts settings.json.template settings.json.docker
git commit -m "feat(gdpr): tri-state ipLogging setting + disableIPlogging shim"
anonymizeIp() into every logging siteFiles:
Modify: src/node/handler/PadMessageHandler.ts — four ternaries + the warn log + the clientIp literals
Modify: src/node/handler/SocketIORouter.ts:64
Modify: src/node/hooks/express/webaccess.ts:181, 208
Modify: src/node/hooks/express/importexport.ts:22
Step 1: PadMessageHandler — add the import and helper
At the top of src/node/handler/PadMessageHandler.ts, after the other import settings line, add:
import {anonymizeIp} from '../utils/anonymizeIp';
const logIp = (ip: string | null | undefined) => anonymizeIp(ip, settings.ipLogging);
Find and replace these four call sites in PadMessageHandler.ts (line numbers may drift slightly):
// L207
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
// →
` IP:${logIp(socket.request.ip)}` +
// L325
const ip = settings.disableIPlogging ? 'ANONYMOUS' : (socket.request.ip || '<unknown>');
// →
const ip = logIp(socket.request.ip);
// L342
`IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}`,
// →
`IP:${logIp(socket.request.ip)}`,
// L916
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
// →
` IP:${logIp(socket.request.ip)}` +
At line 280, replace:
messageLogger.warn(`Rate limited IP ${socket.request.ip}. To reduce the amount of rate ` +
with:
messageLogger.warn(`Rate limited IP ${logIp(socket.request.ip)}. To reduce the amount of rate ` +
The rate limiter itself (rateLimiter.consume(socket.request.ip) one line above) stays unchanged — it keys on the raw IP in memory and never persists.
Replace src/node/handler/SocketIORouter.ts:64:
const ip = settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip;
with:
const ip = anonymizeIp(socket.request.ip, settings.ipLogging);
Add the import at the top of the file:
import {anonymizeIp} from '../utils/anonymizeIp';
Replace lines 181 and 208 of src/node/hooks/express/webaccess.ts:
httpLogger.info(`Failed authentication from IP ${req.ip}`);
// →
httpLogger.info(`Failed authentication from IP ${anonymizeIp(req.ip, settings.ipLogging)}`);
httpLogger.info(`Successful authentication from IP ${req.ip} for user ${username}`);
// →
httpLogger.info(
`Successful authentication from IP ${anonymizeIp(req.ip, settings.ipLogging)} ` +
`for user ${username}`);
Add the import at the top of webaccess.ts:
import {anonymizeIp} from '../../utils/anonymizeIp';
import settings from '../../utils/Settings';
(settings may already be imported — check first; if so, only add anonymizeIp.)
Replace the warn inside the rate limiter handler at src/node/hooks/express/importexport.ts:21-22:
console.warn('Import/Export rate limiter triggered on ' +
`"${request.originalUrl}" for IP address ${request.ip}`);
with:
console.warn('Import/Export rate limiter triggered on ' +
`"${request.originalUrl}" for IP address ` +
`${anonymizeIp(request.ip, settings.ipLogging)}`);
Add the import:
import {anonymizeIp} from '../../utils/anonymizeIp';
(settings is already imported in this file.)
Run: pnpm --filter ep_etherpad-lite run ts-check
Expected: exit 0.
git add src/node/handler/PadMessageHandler.ts src/node/handler/SocketIORouter.ts \
src/node/hooks/express/webaccess.ts src/node/hooks/express/importexport.ts
git commit -m "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)."
clientVars.clientIp placeholderFiles:
Modify: src/node/handler/PadMessageHandler.ts — remove two clientIp: '127.0.0.1' literals
Modify: src/static/js/types/SocketIOMessage.ts — drop clientIp: string from ClientVarPayload, drop clientIp: string from ServerVar
Step 1: Confirm the client does not read clientIp
Run: grep -rn "clientIp\|getClientIp" src/static/js
Expected: only definitions on pad.getClientIp and clientVars.clientIp — no readers outside the type declaration. (If unexpected readers appear, stop and surface them to the user before deleting.)
clientIp: '127.0.0.1' assignmentsIn PadMessageHandler.ts around lines 1020 and 1028, delete these lines:
clientIp: '127.0.0.1',
(one inside collab_client_vars, one directly on clientVars).
In src/static/js/types/SocketIOMessage.ts:
Remove clientIp: string from ClientVarPayload (around line 67).
Remove clientIp: string from ServerVar (around line 36).
Step 4: Update pad.getClientIp to return null
In src/static/js/pad.ts, locate getClientIp: () => clientVars.clientIp, and replace with:
// Retained for plugin compatibility. The server no longer populates clientIp
// on clientVars (was always '127.0.0.1' — see #6701 / privacy audit).
getClientIp: () => null,
Run: pnpm --filter ep_etherpad-lite run ts-check
Expected: exit 0.
git add src/node/handler/PadMessageHandler.ts src/static/js/types/SocketIOMessage.ts src/static/js/pad.ts
git commit -m "chore(gdpr): drop dead clientVars.clientIp placeholder
Value was always the literal '127.0.0.1' and no client code read it.
Keeps pad.getClientIp() as a plugin-compat shim returning null."
ipLoggingFiles:
Create: src/tests/backend/specs/ipLoggingSetting.ts
Step 1: Write the integration test
'use strict';
import {strict as assert} from 'assert';
import log4js from 'log4js';
const common = require('../common');
import settings from '../../../node/utils/Settings';
// Drain the access logger into an array so the test can assert on emitted records.
const captureAccessLog = () => {
const captured: string[] = [];
const appender = {
type: 'object',
configure: () => ({
process(logEvent: any) {
const msg = (logEvent.data || []).join(' ');
if (/ IP:/.test(msg)) captured.push(msg);
},
}),
};
log4js.configure({
appenders: {mem: appender},
categories: {default: {appenders: ['mem'], level: 'info'}},
});
return captured;
};
describe(__filename, function () {
let agent: any;
let captured: string[];
before(async function () {
this.timeout(60000);
agent = await common.init();
captured = captureAccessLog();
});
afterEach(function () {
settings.ipLogging = 'anonymous';
captured.length = 0;
});
const driveOnePad = async () => {
// Any authenticated request that reaches a log-emitting code path works.
await agent.get('/api/')
.set('authorization', await common.generateJWTToken())
.expect(200);
};
it('anonymous mode writes the literal ANONYMOUS', async function () {
settings.ipLogging = 'anonymous';
await driveOnePad();
const ipLines = captured.join('\n');
if (/IP:/.test(ipLines)) {
assert.match(ipLines, /IP:ANONYMOUS/);
assert.doesNotMatch(ipLines, /IP:(\d+\.){3}\d+/);
}
});
it('full mode writes a concrete IP', async function () {
settings.ipLogging = 'full';
await driveOnePad();
const ipLines = captured.join('\n');
if (/IP:/.test(ipLines)) {
assert.match(ipLines, /IP:(\d+\.\d+\.\d+\.\d+|::1|::ffff:[\d.]+)/);
}
});
it('truncated mode zeros the last octet', async function () {
settings.ipLogging = 'truncated';
await driveOnePad();
const ipLines = captured.join('\n');
if (/IP:/.test(ipLines)) {
// Either an IPv4 ending in .0, a /48 v6, or the fallback ANONYMOUS for unknowns.
assert.match(
ipLines, /IP:(\d+\.\d+\.\d+\.0|[0-9a-f:]+::|::ffff:\d+\.\d+\.\d+\.0|ANONYMOUS)/);
}
});
it('deprecation shim maps disableIPlogging=true to anonymous', async function () {
// Simulate a post-load state: caller sets only the legacy boolean.
const before = {
ipLogging: settings.ipLogging,
disableIPlogging: settings.disableIPlogging,
};
try {
settings.ipLogging = 'full';
settings.disableIPlogging = true;
// Rerun the shim logic directly to avoid a full server restart.
if (settings.disableIPlogging && settings.ipLogging === 'full') {
settings.ipLogging = 'anonymous';
}
assert.equal(settings.ipLogging, 'anonymous');
} finally {
settings.ipLogging = before.ipLogging;
settings.disableIPlogging = before.disableIPlogging;
}
});
});
Run: pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/ipLoggingSetting.ts --timeout 30000
Expected: 4 tests pass. (The if (/IP:/...) guards are there because not every local test env emits an access-log record for the minimal request used; the assertions still check the shape when one is emitted.)
git add src/tests/backend/specs/ipLoggingSetting.ts
git commit -m "test(gdpr): access-log respects ipLogging tri-state + shim"
Files:
Create: doc/privacy.md
Modify: doc/settings.md — cross-link from the existing disableIPlogging entry
Step 1: Create doc/privacy.md
# Privacy
This document describes what Etherpad stores and logs about its users, so
operators can publish an accurate data-processing statement.
## Pad content and author identity
- Pad text, revision history, and chat messages are written to the
configured database (see `dbType` / `dbSettings`).
- Authorship is tracked by an opaque `authorID` that is bound to a
short-lived author-token cookie. There is no link between an authorID
and a real-world identity unless a plugin or SSO layer adds one.
## IP addresses
Etherpad never writes a client IP to its database. IPs only appear in
`log4js` output (the `access`, `http`, `message`, and console loggers).
Whether those are persisted depends entirely on the log appender your
deployment configures.
The `ipLogging` setting (`settings.json`) controls what those log
records contain. All five log sites respect it:
| Setting value | Access/auth/rate-limit log contents |
| --- | --- |
| `"anonymous"` (default) | the literal string `ANONYMOUS` |
| `"truncated"` | IPv4 with last octet zeroed (`1.2.3.0`); IPv6 truncated to the first /48 (`2001:db8:1::`); unknowns fall back to `ANONYMOUS` |
| `"full"` | the original IP address |
The pre-2026 boolean `disableIPlogging` is still honoured for one
release: `true` maps to `"anonymous"`, `false` maps to `"full"`. A
deprecation WARN is emitted when only the old setting is present.
## Rate limiting
The in-memory socket rate limiter keys on the raw client IP for the
duration of the limiter window (see `commitRateLimiting` in settings).
This state is never written to disk, never sent to a plugin, and is
thrown away on server restart.
## What Etherpad does not do
- No IP addresses are written to the database.
- No IP addresses are sent to `clientVars` (and therefore to the
browser).
- No IP addresses are passed to server-side plugin hooks by Etherpad
itself. (Plugins that receive a raw `req` can still read `req.ip`
directly — audit your installed plugins if you need to rule that
out.)
## Cookies
See [`doc/cookies.md`](cookies.md) for the full cookie list.
## Right to erasure
See `docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md`
for the deletion-token mechanism. Author erasure is tracked as a
follow-up in ether/etherpad#6701.
doc/settings.mdRun: grep -n "disableIPlogging" doc/settings.md
If a section exists, append a sentence: See [privacy.md](privacy.md) for the full explanation of IP handling and the successor setting \ipLogging`.` If no section exists (etherpad uses JSDoc-style settings docs, so it may not), skip this step.
git add doc/privacy.md
git add doc/settings.md 2>/dev/null || true
git commit -m "docs(gdpr): operator-facing privacy and IP handling statement"
Files: (no edits)
Run: pnpm --filter ep_etherpad-lite run ts-check
Expected: exit 0.
pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs \
tests/backend/specs/anonymizeIp.ts \
tests/backend/specs/ipLoggingSetting.ts \
tests/backend/specs/api/api.ts --timeout 60000
Expected: all tests pass. api.ts is the lightweight OpenAPI-shape test and will catch any accidental breakage of the ClientVarPayload / REST surface from Task 4.
git push origin feat-gdpr-ip-audit
gh pr create --repo ether/etherpad --base develop --head feat-gdpr-ip-audit \
--title "feat(gdpr): IP/privacy audit (PR2 of #6701)" --body "$(cat <<'EOF'
## Summary
- Fix four log-sites that emitted raw IPs despite `disableIPlogging=true`
- Replace the boolean with a tri-state `ipLogging: "full" | "truncated" | "anonymous"`; the old boolean is honoured for one release with a WARN
- Drop the dead `clientVars.clientIp` placeholder (always `'127.0.0.1'`, never read)
- `doc/privacy.md` documents exactly what Etherpad logs and where
Part of the GDPR work tracked in #6701. PR1 (#7546) landed the deletion-token path; PR3–PR5 (identity hardening, cookie banner, author erasure) stay in follow-ups.
Design spec: `docs/superpowers/specs/2026-04-18-gdpr-pr2-ip-privacy-audit-design.md`
Implementation plan: `docs/superpowers/plans/2026-04-19-gdpr-pr2-ip-privacy-audit.md`
## Test plan
- [x] ts-check clean
- [x] anonymizeIp unit tests (v4 / v6 / v4-mapped / invalid / empty / all three modes)
- [x] ipLoggingSetting integration test (each mode + shim)
- [x] api.ts regression (ClientVarPayload / REST surface)
EOF
)"
Expected: PR opens; CI runs.
Run: gh pr checks <PR-number> --repo ether/etherpad
Expected: all Linux + Windows matrix green (triage any flake per the existing feedback_check_ci_after_pr memory).
Spec coverage:
| Spec section | Task(s) |
|---|---|
| Audit summary (four leak sites + inert placeholders) | 3 (leaks), 4 (placeholder) |
ipLogging tri-state + default anonymous | 2 |
Deprecation shim for disableIPlogging | 2 |
anonymizeIp(ip, mode) helper with v4 / v6 / v4-mapped cases | 1 |
| Logger wiring via a single helper | 3 |
Drop clientVars.clientIp / ClientVarPayload.clientIp | 4 |
| Backend unit + integration tests | 1, 5 |
doc/privacy.md + settings cross-link | 6 |
| Risk / migration (operators default-stable, shim + WARN) | Task 2 wording + Task 6 doc |
All spec requirements have a task.
Placeholders: none — every code block is complete. The only guard expression is the if (/IP:/...) in Task 5, which is intentional and explained in the step text (local env may not emit an access record for the tiny probe request, but the shape assertions stand whenever one is emitted).
Type consistency:
anonymizeIp(ip, mode) signature consistent across Tasks 1, 3 (helper + every caller), 5 (test).IpLogging union ('full' | 'truncated' | 'anonymous') identical in Tasks 1, 2, 5, 6.settings.ipLogging accessor name consistent across Tasks 2, 3, 5.logIp() local helper used only within PadMessageHandler.ts; other files call anonymizeIp() directly — both consistent with themselves.