From a6807ef7a46c67af96eceb467effc0aae63706a4 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 8 May 2026 19:08:29 +0200 Subject: [PATCH] add hardening and metrics tests --- src/utils/metrics.js | 77 ++++++++++++++++++++++++++++++++++ src/utils/urlSafety.js | 42 +++++++++++++++++++ test/backend-hardening.test.js | 67 +++++++++++++++++++++++++++++ test/metrics.test.js | 58 +++++++++++++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 src/utils/metrics.js create mode 100644 src/utils/urlSafety.js create mode 100644 test/backend-hardening.test.js create mode 100644 test/metrics.test.js diff --git a/src/utils/metrics.js b/src/utils/metrics.js new file mode 100644 index 0000000..0a3e40c --- /dev/null +++ b/src/utils/metrics.js @@ -0,0 +1,77 @@ +const client = require('prom-client'); + +const register = new client.Registry(); + +client.collectDefaultMetrics({ + prefix: 'ped_ai_', + register +}); + +const httpRequestsTotal = new client.Counter({ + name: 'ped_ai_http_requests_total', + help: 'Total HTTP requests received by Ped-AI', + labelNames: ['method', 'route', 'status_code'], + registers: [register] +}); + +const httpRequestDuration = new client.Histogram({ + name: 'ped_ai_http_request_duration_seconds', + help: 'HTTP request duration in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + registers: [register] +}); + +const httpRequestsInProgress = new client.Gauge({ + name: 'ped_ai_http_requests_in_progress', + help: 'Number of Ped-AI HTTP requests currently in progress', + labelNames: ['method', 'route'], + registers: [register] +}); + +function normalizeRoute(req) { + var routePath = req.route && req.route.path; + var baseUrl = req.baseUrl || ''; + if (Array.isArray(routePath)) return baseUrl + routePath[0]; + if (routePath) return baseUrl + routePath; + + var path = req.path || req.url || 'unknown'; + return path + .replace(/\b[0-9a-f]{8,}\b/gi, ':id') + .replace(/\b\d+\b/g, ':id') + .replace(/\?.*$/, ''); +} + +function metricsMiddleware(req, res, next) { + if (req.path === '/metrics') return next(); + + var route = normalizeRoute(req); + var method = req.method; + var endTimer = httpRequestDuration.startTimer({ method, route }); + httpRequestsInProgress.inc({ method, route }); + + res.on('finish', function() { + var finalRoute = normalizeRoute(req); + var labels = { + method, + route: finalRoute, + status_code: String(res.statusCode) + }; + httpRequestsTotal.inc(labels); + endTimer({ route: finalRoute, status_code: String(res.statusCode) }); + httpRequestsInProgress.dec({ method, route }); + }); + + next(); +} + +async function metricsHandler(req, res) { + res.setHeader('Content-Type', register.contentType); + res.end(await register.metrics()); +} + +module.exports = { + metricsHandler, + metricsMiddleware, + register +}; diff --git a/src/utils/urlSafety.js b/src/utils/urlSafety.js new file mode 100644 index 0000000..a36dd3c --- /dev/null +++ b/src/utils/urlSafety.js @@ -0,0 +1,42 @@ +var dns = require('dns').promises; +var net = require('net'); + +function isPrivateIp(ip) { + if (!ip) return true; + if (net.isIPv4(ip)) { + var p = ip.split('.').map(Number); + if (p[0] === 10 || p[0] === 127 || p[0] === 0) return true; + if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true; + if (p[0] === 169 && p[1] === 254) return true; + if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true; + if (p[0] === 192 && p[1] === 168) return true; + if (p[0] >= 224) return true; + return false; + } + if (net.isIPv6(ip)) { + var low = ip.toLowerCase(); + if (low === '::1' || low === '::') return true; + if (low.startsWith('fe80:') || low.startsWith('fc') || low.startsWith('fd')) return true; + return false; + } + return true; +} + +async function assertSafeHttpsUrl(urlStr, label) { + var u; + try { u = new URL(urlStr); } + catch (e) { throw new Error((label || 'URL') + ' is invalid'); } + if (u.protocol !== 'https:') throw new Error((label || 'URL') + ' must use https://'); + if (u.username || u.password) throw new Error((label || 'URL') + ' must not include credentials'); + var addrs = await dns.lookup(u.hostname, { all: true }); + if (!addrs.length) throw new Error((label || 'URL') + ' did not resolve'); + for (var i = 0; i < addrs.length; i++) { + if (isPrivateIp(addrs[i].address)) throw new Error((label || 'URL') + ' resolves to a private IP'); + } + return u; +} + +module.exports = { + assertSafeHttpsUrl: assertSafeHttpsUrl, + isPrivateIp: isPrivateIp +}; diff --git a/test/backend-hardening.test.js b/test/backend-hardening.test.js new file mode 100644 index 0000000..e1d54c8 --- /dev/null +++ b/test/backend-hardening.test.js @@ -0,0 +1,67 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +function read(relativePath) { + return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8'); +} + +test('redactor removes PHI and common secret patterns from logs', () => { + const { redact } = require('../src/utils/redact'); + const input = 'Authorization: Bearer abc.def.ghi password=secret123 token=tok_1234567890 email test@example.com MRN 123456789'; + const output = redact(input); + + assert.doesNotMatch(output, /abc\.def\.ghi/); + assert.doesNotMatch(output, /secret123/); + assert.doesNotMatch(output, /tok_1234567890/); + assert.doesNotMatch(output, /test@example\.com/); + assert.doesNotMatch(output, /123456789/); + assert.match(output, /\[REDACTED\]|\[JWT\]/); +}); + +test('URL safety helper blocks private network targets', () => { + const { isPrivateIp } = require('../src/utils/urlSafety'); + + assert.equal(isPrivateIp('127.0.0.1'), true); + assert.equal(isPrivateIp('10.0.0.5'), true); + assert.equal(isPrivateIp('172.16.0.1'), true); + assert.equal(isPrivateIp('192.168.1.10'), true); + assert.equal(isPrivateIp('169.254.169.254'), true); + assert.equal(isPrivateIp('100.64.0.1'), true); + assert.equal(isPrivateIp('::1'), true); + assert.equal(isPrivateIp('fe80::1'), true); + assert.equal(isPrivateIp('8.8.8.8'), false); + assert.equal(isPrivateIp('2606:4700:4700::1111'), false); +}); + +test('Nextcloud/WebDAV routes enforce SSRF guard and redirect blocking', () => { + const nextcloud = read('src/routes/nextcloud.js'); + const learningAI = read('src/routes/learningAI.js'); + + assert.match(nextcloud, /assertSafeHttpsUrl\(cleanUrl, 'Nextcloud URL'\)/); + assert.match(nextcloud, /assertSafeHttpsUrl\(user\.nextcloud_url, 'Nextcloud URL'\)/); + assert.match(nextcloud, /maxRedirects: 0/); + assert.match(nextcloud, /encodeURIComponent\(username\)/); + assert.match(learningAI, /assertSafeHttpsUrl\(user\.nextcloud_url, 'Nextcloud URL'\)/); + assert.match(learningAI, /maxRedirects: 0/); + assert.match(learningAI, /encodeURIComponent\(user\.nextcloud_user\)/); +}); + +test('logs and audits avoid unbounded limits and PHI-prone details', () => { + const logs = read('src/routes/logs.js'); + const encounters = read('src/routes/encounters.js'); + const documents = read('src/routes/documents.js'); + const learningAI = read('src/routes/learningAI.js'); + + assert.match(logs, /function clampLimit/); + assert.match(logs, /clampLimit\(req\.query\.limit, 50, 200\)/); + assert.match(logs, /logger\.warn\('client_error'/); + assert.match(logs, /redact\(trimField\(e\.stack, 1200\)\)/); + assert.doesNotMatch(logs, /console\.error\('\[CLIENT ERROR\]'/); + + assert.doesNotMatch(encounters, /Saved encounter: ' \+ \(label/); + assert.doesNotMatch(encounters, /Loaded encounter: ' \+ \(row\.label/); + assert.doesNotMatch(documents, /Uploaded: ' \+ \(req\.file/); + assert.doesNotMatch(learningAI, /Char context/); +}); diff --git a/test/metrics.test.js b/test/metrics.test.js new file mode 100644 index 0000000..c39e563 --- /dev/null +++ b/test/metrics.test.js @@ -0,0 +1,58 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const EventEmitter = require('node:events'); + +const { metricsHandler, metricsMiddleware, register } = require('../src/utils/metrics'); + +test('metrics middleware records HTTP request counters and durations', async () => { + const req = { + method: 'GET', + path: '/api/encounters/123', + url: '/api/encounters/123', + baseUrl: '', + route: null + }; + const res = new EventEmitter(); + res.statusCode = 200; + + await new Promise((resolve) => metricsMiddleware(req, res, resolve)); + res.emit('finish'); + + const metrics = await register.metrics(); + assert.match(metrics, /ped_ai_http_requests_total/); + assert.match(metrics, /route="\/api\/encounters\/:id"/); + assert.match(metrics, /ped_ai_http_request_duration_seconds_count/); +}); + +test('metrics middleware uses the first path for Express array routes', async () => { + const req = { + method: 'GET', + path: '/', + url: '/', + baseUrl: '', + route: { path: ['/', '/index.html'] } + }; + const res = new EventEmitter(); + res.statusCode = 200; + + await new Promise((resolve) => metricsMiddleware(req, res, resolve)); + res.emit('finish'); + + const metrics = await register.metrics(); + assert.match(metrics, /route="\/"/); + assert.doesNotMatch(metrics, /route="\/,\/index\.html"/); +}); + +test('metrics handler exposes prometheus content type', async () => { + const headers = {}; + const res = { + body: '', + setHeader: function(name, value) { headers[name] = value; }, + end: function(body) { this.body = body; } + }; + + await metricsHandler({}, res); + + assert.equal(headers['Content-Type'], register.contentType); + assert.match(res.body, /ped_ai_process_cpu_user_seconds_total/); +});