etherpad-lite/src/tests/backend/specs/ipLoggingSetting.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

137 lines
5.2 KiB
TypeScript

'use strict';
import {strict as assert} from 'assert';
import fs from 'node:fs';
import path from 'node:path';
import settings from '../../../node/utils/Settings';
import {anonymizeIp} from '../../../node/utils/anonymizeIp';
describe(__filename, function () {
const backup = {ipLogging: settings.ipLogging, disableIPlogging: settings.disableIPlogging};
afterEach(function () {
settings.ipLogging = backup.ipLogging;
settings.disableIPlogging = backup.disableIPlogging;
});
describe('settings.ipLogging is honoured by anonymizeIp', function () {
it('anonymous mode redacts a concrete IPv4', function () {
settings.ipLogging = 'anonymous';
assert.equal(anonymizeIp('8.8.8.8', settings.ipLogging), 'ANONYMOUS');
});
it('full mode passes the IP through unchanged', function () {
settings.ipLogging = 'full';
assert.equal(anonymizeIp('8.8.8.8', settings.ipLogging), '8.8.8.8');
});
it('truncated mode zeros the last v4 octet', function () {
settings.ipLogging = 'truncated';
assert.equal(anonymizeIp('8.8.8.8', settings.ipLogging), '8.8.8.0');
});
it('truncated mode keeps the first /48 of a v6 address', function () {
settings.ipLogging = 'truncated';
assert.equal(anonymizeIp('2001:db8::1', settings.ipLogging), '2001:db8::');
});
});
describe('disableIPlogging → ipLogging deprecation shim', function () {
// Replicates the shim block from Settings.ts::reloadSettings so we can
// assert the mapping without rebooting the whole server in this spec.
const applyShim = (parsed: Record<string, any>) => {
if (parsed != null && 'disableIPlogging' in parsed && !('ipLogging' in parsed)) {
settings.ipLogging = parsed.disableIPlogging ? 'anonymous' : 'full';
}
};
it('maps disableIPlogging=true to ipLogging=anonymous', function () {
settings.ipLogging = 'full';
applyShim({disableIPlogging: true});
assert.equal(settings.ipLogging, 'anonymous');
});
it('maps disableIPlogging=false to ipLogging=full', function () {
settings.ipLogging = 'anonymous';
applyShim({disableIPlogging: false});
assert.equal(settings.ipLogging, 'full');
});
it('leaves ipLogging alone when the operator set both', function () {
settings.ipLogging = 'truncated';
applyShim({disableIPlogging: true, ipLogging: 'truncated'});
assert.equal(settings.ipLogging, 'truncated');
});
it('does nothing when neither key is present', function () {
settings.ipLogging = 'anonymous';
applyShim({});
assert.equal(settings.ipLogging, 'anonymous');
});
});
describe('every known log-site routes IPs through anonymizeIp', function () {
// Regression guard: if any of these files ever log `req.ip` /
// `socket.request.ip` / `request.ip` directly again without wrapping
// through anonymizeIp or logIp, this test fails and CI blocks the merge.
const repoRoot = path.resolve(__dirname, '..', '..', '..', '..');
const cases: Array<{file: string, ipExpressions: RegExp[]}> = [
{
file: 'src/node/handler/PadMessageHandler.ts',
ipExpressions: [/socket\.request\.ip/g],
},
{
file: 'src/node/handler/SocketIORouter.ts',
ipExpressions: [/socket\.request\.ip/g],
},
{
file: 'src/node/hooks/express/webaccess.ts',
ipExpressions: [/req\.ip/g],
},
{
file: 'src/node/hooks/express/importexport.ts',
ipExpressions: [/request\.ip/g],
},
];
for (const {file, ipExpressions} of cases) {
it(`${file} does not log a raw IP`, function () {
const content = fs.readFileSync(path.join(repoRoot, file), 'utf8');
// Split into lines and inspect only those that also reference a logger
// — the rate limiter consume() call is allowed to pass the raw IP.
const offending: string[] = [];
for (const line of content.split('\n')) {
if (!/(?:accessLogger|messageLogger|httpLogger|logger|console)\.(?:info|warn|error|debug|log)|backtick.*IP/i
.test(line) && !line.includes('IP:') && !line.includes('IP address')) continue;
if (line.includes('anonymizeIp') || line.includes('logIp(')) continue;
for (const re of ipExpressions) {
if (re.test(line)) {
offending.push(line.trim());
break;
}
}
}
assert.deepEqual(offending, [],
`found raw IP(s) in log lines of ${file}:\n${offending.join('\n')}`);
});
}
});
describe('invalid ipLogging falls back to anonymous at load time', function () {
it('rejects an unknown mode', function () {
// Replicate the validation block directly so we don't need to reload.
const valid = ['full', 'truncated', 'anonymous'];
let mode: any = 'lolnope';
if (!valid.includes(mode)) mode = 'anonymous';
assert.equal(mode, 'anonymous');
assert.equal(anonymizeIp('8.8.8.8', mode), 'ANONYMOUS');
});
it('rejects null', function () {
const valid = ['full', 'truncated', 'anonymous'];
let mode: any = null;
if (!valid.includes(mode)) mode = 'anonymous';
assert.equal(mode, 'anonymous');
});
});
});