mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
getClientIp() decides the value every per-IP control keys on — the auth/pairing rate limiters, lib/pair-lockout, and activity_log.ip_address — so a caller must never be able to choose it. It believed CF-Connecting-IP whenever the immediate peer was in the `trust proxy` list, which includes loopback/linklocal/uniquelocal. Those entries are correct for X-Forwarded-For: a proxy APPENDS to that header and Express walks the chain right-to-left, so a client-supplied value cannot become the resolved address. CF-Connecting-IP has no chain — a local reverse proxy passes through whatever single value the client sent — so treating a loopback peer as evidence the request came through Cloudflare means trusting the client. Gate it on the published Cloudflare ranges alone. This is also the portable behaviour: most self-hosted installs are not behind Cloudflare, and for them the header is now simply ignored, with attribution falling back to req.ip under whatever `trust proxy` the operator configured. Installs that do front with Cloudflare are unaffected — their peer really is a CF edge. Documented the distinction at config/cloudflareIps.js so the two lists are not conflated again. No response shape or DB change; no client impact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
67 lines
3.4 KiB
JavaScript
67 lines
3.4 KiB
JavaScript
'use strict';
|
|
|
|
// getClientIp() decides the value every per-IP control keys on: the login/register/pairing
|
|
// rate limiters, lib/pair-lockout, and the ip_address column in activity_log. If a caller
|
|
// can choose that value, all of those controls are advisory.
|
|
//
|
|
// The invariant: `CF-Connecting-IP` is only believed when the request genuinely arrived
|
|
// through Cloudflare — i.e. the immediate TCP peer is a Cloudflare edge address. A local
|
|
// reverse proxy (loopback / LAN / unique-local) is NOT evidence of that: nginx forwards
|
|
// whatever header the client sent, so trusting the header on a loopback peer means
|
|
// trusting the client.
|
|
//
|
|
// This matters most for SELF-HOSTED installs, which are the majority and mostly do NOT
|
|
// use Cloudflare: for them the header must simply be ignored, and attribution falls back
|
|
// to req.ip via each operator's own `trust proxy` setting.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { getClientIp } = require('../services/activity');
|
|
const { cloudflareIpv4 } = require('../config/cloudflareIps');
|
|
|
|
// A real address inside Cloudflare's published v4 ranges (173.245.48.0/20).
|
|
const CF_EDGE = '173.245.48.1';
|
|
const NOT_CF = '203.0.113.10'; // TEST-NET-3 — never a Cloudflare edge
|
|
|
|
function reqFrom(peer, headers = {}, reqIp = undefined) {
|
|
return { headers, socket: { remoteAddress: peer }, ip: reqIp === undefined ? peer : reqIp };
|
|
}
|
|
|
|
test('a Cloudflare edge peer IS believed (hosted deployments keep working)', () => {
|
|
assert.ok(cloudflareIpv4.includes('173.245.48.0/20'), 'fixture address is in a published CF range');
|
|
const ip = getClientIp(reqFrom(CF_EDGE, { 'cf-connecting-ip': '198.51.100.7' }, CF_EDGE));
|
|
assert.equal(ip, '198.51.100.7', 'through Cloudflare, the header is the real client');
|
|
});
|
|
|
|
test('a LOOPBACK peer is not evidence of Cloudflare — the header is ignored', () => {
|
|
// This is the self-hosted-behind-nginx shape: nginx is the peer, and it forwards any
|
|
// CF-Connecting-IP the client invented.
|
|
const ip = getClientIp(reqFrom('127.0.0.1', { 'cf-connecting-ip': '203.0.113.77' }, '203.0.113.200'));
|
|
assert.notEqual(ip, '203.0.113.77', 'a forged CF-Connecting-IP must not become the client IP');
|
|
assert.equal(ip, '203.0.113.200', 'attribution falls back to req.ip (operator trust-proxy config)');
|
|
});
|
|
|
|
test('a LAN / unique-local peer is not evidence of Cloudflare either', () => {
|
|
for (const peer of ['10.0.0.5', '192.168.1.10', 'fd00::1']) {
|
|
const ip = getClientIp(reqFrom(peer, { 'cf-connecting-ip': '203.0.113.77' }, '198.51.100.99'));
|
|
assert.notEqual(ip, '203.0.113.77', `forged header honoured for peer ${peer}`);
|
|
}
|
|
});
|
|
|
|
test('a direct, non-proxied caller cannot self-attribute', () => {
|
|
const ip = getClientIp(reqFrom(NOT_CF, { 'cf-connecting-ip': '1.2.3.4' }, NOT_CF));
|
|
assert.equal(ip, NOT_CF, 'the header from an untrusted peer is ignored');
|
|
});
|
|
|
|
test('no CF header at all -> req.ip, unchanged for every non-Cloudflare install', () => {
|
|
assert.equal(getClientIp(reqFrom('203.0.113.5', {}, '203.0.113.5')), '203.0.113.5');
|
|
});
|
|
|
|
test('malformed / empty header values fall through rather than throwing', () => {
|
|
for (const v of ['', ' ', 'not-an-ip', undefined]) {
|
|
const r = reqFrom('127.0.0.1', v === undefined ? {} : { 'cf-connecting-ip': v }, '198.51.100.1');
|
|
assert.doesNotThrow(() => getClientIp(r));
|
|
assert.equal(getClientIp(r), '198.51.100.1');
|
|
}
|
|
});
|