diff --git a/server/index.js b/server/index.js index b6f6544..63b4715 100644 --- a/server/index.js +++ b/server/index.js @@ -3,6 +3,7 @@ import express from 'express'; import cors from 'cors'; import path from 'path'; import { fileURLToPath } from 'url'; +import requestResponseLogger from './middleware/logging.js'; import { initDatabase } from './config/database.js'; import { initMailer } from './config/mailer.js'; import authRoutes from './routes/auth.js'; @@ -31,6 +32,8 @@ const corsOptions = process.env.APP_URL : {}; app.use(cors(corsOptions)); app.use(express.json()); +// Request/Response logging (filters sensitive fields) +app.use(requestResponseLogger); // Initialize database & start server async function start() { diff --git a/server/middleware/logging.js b/server/middleware/logging.js new file mode 100644 index 0000000..b13ef17 --- /dev/null +++ b/server/middleware/logging.js @@ -0,0 +1,130 @@ +import util from 'util'; + +const SENSITIVE_KEYS = [/pass/i, /pwd/i, /password/i, /token/i, /jwt/i, /secret/i, /authorization/i, /auth/i, /api[_-]?key/i]; +const MAX_LOG_BODY_LENGTH = 200000; // 200 KB + +function isSensitiveKey(key) { + return SENSITIVE_KEYS.some(rx => rx.test(key)); +} + +function filterValue(key, value, depth = 0) { + if (depth > 5) return '[MAX_DEPTH]'; + if (key && isSensitiveKey(key)) return '[FILTERED]'; + if (value === null || value === undefined) return value; + if (typeof value === 'string') { + if (value.length > MAX_LOG_BODY_LENGTH) return '[TOO_LARGE]'; + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') return value; + if (Array.isArray(value)) return value.map(v => filterValue(null, v, depth + 1)); + if (typeof value === 'object') { + const out = {}; + for (const k of Object.keys(value)) { + out[k] = filterValue(k, value[k], depth + 1); + } + return out; + } + return typeof value; +} + +function filterHeaders(headers) { + const out = {}; + for (const k of Object.keys(headers || {})) { + if (/^authorization$/i.test(k) || /^cookie$/i.test(k)) { + out[k] = '[FILTERED]'; + continue; + } + if (isSensitiveKey(k)) { + out[k] = '[FILTERED]'; + continue; + } + out[k] = headers[k]; + } + return out; +} + +export default function requestResponseLogger(req, res, next) { + try { + const start = Date.now(); + const { method, originalUrl } = req; + const ip = req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress; + + const reqHeaders = filterHeaders(req.headers); + + let reqBody = '[not-logged]'; + const contentType = (req.headers['content-type'] || '').toLowerCase(); + if (contentType.includes('multipart/form-data')) { + reqBody = '[multipart/form-data]'; + } else if (req.body) { + try { + reqBody = filterValue(null, req.body); + } catch (e) { + reqBody = '[unserializable]'; + } + } + + // Capture response body by wrapping res.send + const oldSend = res.send.bind(res); + let responseBody = undefined; + res.send = function sendOverWrite(body) { + responseBody = body; + return oldSend(body); + }; + + res.on('finish', () => { + try { + const duration = Date.now() - start; + const resHeaders = filterHeaders(res.getHeaders ? res.getHeaders() : {}); + + let loggedResponseBody = null; + const resContentType = (res.getHeader && (res.getHeader('content-type') || '')).toString().toLowerCase(); + if (responseBody === undefined) { + loggedResponseBody = '[none-captured]'; + } else if (resContentType.includes('application/json')) { + try { + const parsed = typeof responseBody === 'string' ? JSON.parse(responseBody) : responseBody; + loggedResponseBody = filterValue(null, parsed); + } catch (e) { + loggedResponseBody = typeof responseBody === 'string' ? (responseBody.length > 1000 ? responseBody.slice(0, 1000) + '...[truncated]' : responseBody) : util.inspect(responseBody); + } + } else if (resContentType.includes('text/') || resContentType.includes('application/xml')) { + const asStr = typeof responseBody === 'string' ? responseBody : util.inspect(responseBody); + loggedResponseBody = asStr.length > 1000 ? asStr.slice(0, 1000) + '...[truncated]' : asStr; + } else { + loggedResponseBody = '[not-logged-due-to-content-type]'; + } + + const log = { + time: new Date().toISOString(), + ip, + method, + url: originalUrl, + status: res.statusCode, + duration_ms: duration, + request: { + headers: reqHeaders, + query: filterValue(null, req.query || {}), + body: reqBody, + }, + response: { + headers: resHeaders, + body: loggedResponseBody, + }, + }; + + // Output as single-line JSON for log aggregation + try { + console.info(JSON.stringify(log)); + } catch (e) { + console.info('LOG_ERROR', util.inspect(log)); + } + } catch (e) { + console.error('RequestLogger error:', e); + } + }); + } catch (e) { + console.error('RequestLogger setup failure:', e); + } + + return next(); +}