screentinker/server/services/activity.js
ScreenTinker 45a6800621 fix: log real client IPs through Cloudflare instead of CF edge
Express's req.ip was resolving to a Cloudflare edge address (e.g.
172.70.x.x) for any request fronted by Cloudflare, because trust proxy
was set to '1' — that trusts the immediate hop, which IS Cloudflare.
All activity_log rows from API paths captured the proxy, not the
client. The WebSocket path was unaffected and recorded the real IP.

Two layers of defense:

1. trust proxy now lists Cloudflare's published v4 + v6 ranges plus
   loopback / linklocal / uniquelocal (config/cloudflareIps.js). With
   this list req.ip resolves to the original client when fronted by
   CF, and X-Forwarded-For from any non-trusted source is ignored —
   so the value can't be spoofed.

2. New getClientIp(req) helper in services/activity.js prefers the
   CF-Connecting-IP header but only honors it when the immediate TCP
   peer is itself a trusted address. Same gate as trust proxy, so a
   visitor who hits the origin directly with a forged header is
   logged at their real address.

Routed all five activity-log call sites (auth login success/failure,
admin password reset, generic activityLogger middleware, and the
in-memory rate-limiter key) through the helper.

Logging-only change. No schema changes. Existing rows are not
modified — fix applies to new entries going forward.

Verified locally:
- Bare loopback hit logs 127.0.0.1 (not a proxy address).
- Helper unit cases including an untrusted peer (203.0.113.7) sending
  a forged CF-Connecting-IP correctly fall back to the real peer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:26:37 -05:00

88 lines
3.5 KiB
JavaScript

const { db } = require('../db/database');
const proxyaddr = require('proxy-addr');
const { trustedProxies } = require('../config/cloudflareIps');
// Gate function: returns true when an immediate TCP peer is one we trust
// to populate forwarding headers (Cloudflare edges, loopback, link-local,
// unique-local). Mirrors what `app.set('trust proxy', trustedProxies)` does
// for X-Forwarded-For so that CF-Connecting-IP is held to the same standard.
const isTrustedPeer = proxyaddr.compile(trustedProxies);
// Resolve the real client IP for logging.
//
// Cloudflare always sets `CF-Connecting-IP` to the original client address
// when it proxies a request. We prefer that header — but only when the
// connection's immediate peer is a trusted CF/loopback address; otherwise
// any random visitor could spoof the header by hitting the origin directly.
//
// Falls back to req.ip (which Express resolves via the trust-proxy table)
// so local dev and any non-CF deployment keep working unchanged.
function getClientIp(req) {
if (!req) return null;
const cf = req.headers && req.headers['cf-connecting-ip'];
if (typeof cf === 'string' && cf.length > 0) {
const peer = req.socket && req.socket.remoteAddress;
if (peer && isTrustedPeer(peer, 0)) return cf;
}
return req.ip || null;
}
function logActivity(userId, action, details = null, deviceId = null, ipAddress = null) {
try {
db.prepare(
'INSERT INTO activity_log (user_id, device_id, action, details, ip_address) VALUES (?, ?, ?, ?, ?)'
).run(userId || null, deviceId || null, action, details || null, ipAddress || null);
} catch (e) {
console.error('Activity log error:', e.message);
}
}
function getActivity(options = {}) {
const { userId, deviceId, limit = 50, offset = 0 } = options;
let sql = `SELECT al.*, u.name as user_name, u.email as user_email
FROM activity_log al LEFT JOIN users u ON al.user_id = u.id WHERE 1=1`;
const params = [];
if (userId) { sql += ' AND al.user_id = ?'; params.push(userId); }
if (deviceId) { sql += ' AND al.device_id = ?'; params.push(deviceId); }
sql += ' ORDER BY al.created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
return db.prepare(sql).all(...params);
}
// Prune old activity logs (keep 90 days)
function pruneActivityLog() {
db.prepare("DELETE FROM activity_log WHERE created_at < strftime('%s','now') - (90 * 86400)").run();
}
// Express middleware to auto-log API mutations
function activityLogger(req, res, next) {
const originalJson = res.json.bind(res);
res.json = function(data) {
// Only log successful mutations
if (['POST', 'PUT', 'DELETE'].includes(req.method) && res.statusCode < 400) {
const action = `${req.method} ${req.baseUrl || ''}${req.route?.path || req.path}`;
const userId = req.user?.id;
const deviceId = req.params?.id || req.params?.deviceId || req.body?.device_id;
const details = summarizeAction(req);
logActivity(userId, action, details, deviceId, getClientIp(req));
}
return originalJson(data);
};
next();
}
function summarizeAction(req) {
const parts = [];
if (req.body?.name) parts.push(`name: ${req.body.name}`);
if (req.body?.filename) parts.push(`file: ${req.body.filename}`);
if (req.body?.pairing_code) parts.push('device paired');
if (req.body?.plan_id) parts.push(`plan: ${req.body.plan_id}`);
if (req.file?.originalname) parts.push(`uploaded: ${req.file.originalname}`);
return parts.join(', ') || null;
}
module.exports = { logActivity, getActivity, pruneActivityLog, activityLogger, getClientIp };