obscure authorization header value when logging
[squeep-api-dingus] / lib / common.js
1 /* eslint-disable security/detect-object-injection */
2 'use strict';
3
4 /**
5 * Utility and miscellaneous functions.
6 */
7
8 const path = require('path');
9 const crypto = require('crypto');
10 const uuid = require('uuid');
11 const Enum = require('./enum');
12
13 /**
14 * Return a function which combines a part of the filename with a scope, for use in logging.
15 * @param {string} filename
16 */
17 const fileScope = (filename) => {
18 let fScope = path.basename(filename, '.js');
19 if (fScope === 'index') {
20 fScope = path.basename(path.dirname(filename));
21 }
22 return (scope) => `${fScope}:${scope}`;
23 }
24
25 /**
26 * Simple ETag from data.
27 * @param {string} filePath
28 * @param {object} fileStat
29 * @param {*} fileData
30 */
31 const generateETag = (_filePath, fileStat, fileData) => {
32 const hash = crypto.createHash('sha256');
33 if (fileStat && fileStat.mtimeMs) {
34 hash.update(fileStat.mtimeMs.toString());
35 }
36 hash.update(fileData);
37 const digest = hash.digest('base64').replace('=', '');
38 return `"${digest}"`;
39 };
40
41 /**
42 * @param {object} obj
43 * @param {string} prop
44 * @param {*} def
45 */
46 const get = (obj, prop, def) => obj && prop && (prop in obj) ? obj[prop] : def;
47
48 /**
49 * @param {http.ClientRequest} req
50 * @param {http.ServerResponse} res
51 * @param {object} ctx
52 */
53 const handlerLogData = (req, res, ctx) => ({
54 req: requestLogData(req),
55 res: responseLogData(res),
56 ctx,
57 });
58
59 /**
60 *
61 * @param {http.ClientRequest} req
62 * @param {Number} modifiedTimeMs
63 * @param {string} eTag
64 */
65 const isClientCached = (req, modifiedTimeMs, eTag) => {
66 let clientCached = false;
67
68 const ifModifiedSince = req.getHeader(Enum.Header.IfModifiedSince);
69 if (ifModifiedSince) {
70 const ifModifiedSinceMs = Date.parse(ifModifiedSince);
71 if (modifiedTimeMs < ifModifiedSinceMs) {
72 clientCached = true;
73 }
74 }
75
76 const ifNoneMatch = req.getHeader(Enum.Header.IfNoneMatch);
77 if (ifNoneMatch) {
78 const matches = ifNoneMatch.split(',').map((m) => m.trim());
79 if (matches.includes(eTag)
80 || (ifNoneMatch === '*' && eTag)) {
81 clientCached = true;
82 } else {
83 // If if-none-matched header is present, it takes precedence over modified-since.
84 clientCached = false;
85 }
86 }
87
88 return clientCached;
89 };
90
91 /**
92 * Shallow merge for enums, to be called by derived constructor.
93 * Expects only one-level deep, is not recursive!
94 * @param {Object} origEnum
95 * @param {Object} additionalEnum
96 */
97 const mergeEnum = (origEnum, additionalEnum) => {
98 for (const e of Object.keys(additionalEnum)) {
99 if (typeof additionalEnum[e] === 'object') {
100 if (! (e in origEnum)) {
101 origEnum[e] = {};
102 }
103 Object.assign(origEnum[e], additionalEnum[e]);
104 } else {
105 origEnum[e] = additionalEnum[e];
106 }
107 }
108 return origEnum;
109 };
110
111 /**
112 * Isolate the general category of an http status code.
113 * @param {Number} statusCode
114 * @returns {Number}
115 */
116 const httpStatusCodeClass = (statusCode) => Math.floor(statusCode / 100);
117
118 const _isObject = (obj) => obj && typeof obj === 'object';
119 const _isArray = (obj) => Array.isArray(obj);
120 /**
121 * Return a new object with all objects combined.
122 * @param {...any} objects
123 * @returns
124 */
125 const mergeDeep = (...objects) => {
126 return objects.reduce((acc, obj) => {
127 const objectProperties = [...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)];
128 objectProperties.forEach((k) => {
129 const aVal = acc[k];
130 const oVal = obj[k];
131 if (_isArray(oVal)) {
132 acc[k] = (_isArray(aVal) ? aVal : []).concat(oVal);
133 } else if (_isObject(oVal)) {
134 acc[k] = mergeDeep(_isObject(aVal) ? aVal : {}, oVal);
135 } else {
136 acc[k] = oVal;
137 }
138 });
139 return acc;
140 }, {});
141 };
142
143
144 /**
145 * Return a new object with selected props.
146 * @param {Object} obj
147 * @param {string[]} props
148 */
149 const pick = (obj, props) => {
150 const picked = {};
151 props.forEach((prop) => {
152 if (prop in obj) {
153 picked[prop] = obj[prop];
154 }
155 });
156 return picked;
157 };
158
159 /**
160 * Return a subset of a request object, suitable for logging.
161 * Obscures sensitive header values.
162 * @param {http.ClientRequest} req
163 */
164 const requestLogData = (req) => {
165 const data = pick(req, [
166 'method',
167 'url',
168 'httpVersion',
169 'headers',
170 'trailers',
171 ]);
172 scrubHeaderObject(data);
173 return data;
174 };
175
176
177 /**
178 * Remove sensitive header data.
179 * @param {Object} data
180 * @param {Object} data.headers
181 */
182 const scrubHeaderObject = (data) => {
183 if (data && data.headers && 'authorization' in data.headers) {
184 data.headers = Object.assign({}, data.headers, {
185 authorization: obscureAuthorizationHeader(data.headers['authorization']),
186 });
187 }
188 }
189
190
191 /**
192 * Hide sensitive part of an Authorization header.
193 * @param {String} authHeader
194 * @returns {String}
195 */
196 const obscureAuthorizationHeader = (authHeader) => {
197 if (!authHeader) {
198 return authHeader;
199 }
200 const space = authHeader.indexOf(' ');
201 // This blurs entire string if no space found, because -1.
202 return authHeader.slice(0, space + 1) + '*'.repeat(authHeader.length - (space + 1));
203 }
204
205
206 /**
207 * Return a subset of a response object, suitable for logging.
208 * @param {http.ServerResponse} res
209 */
210 const responseLogData = (res) => {
211 const response = pick(res, [
212 'statusCode',
213 'statusMessage',
214 ]);
215 response.headers = res.getHeaders();
216 return response;
217 };
218
219
220 /**
221 * Store updates to defaultOptions, but no new properties.
222 * @param {Object} target
223 * @param {Object} defaultOptions
224 * @param {Object} options
225 */
226 const setOptions = (target, defaultOptions, options) => {
227 Object.assign(target, defaultOptions, pick(options, Object.keys(defaultOptions)));
228 };
229
230 /**
231 * Return a list of source split at first delimiter.
232 * @param {string} src
233 * @param {string} delimiter
234 * @param {string} fill trailing stand-in if no delimiter in src
235 */
236 const splitFirst = (src, delimiter, fill) => {
237 const idx = src.indexOf(delimiter);
238 if (idx >= 0) {
239 return [ src.slice(0, idx), src.slice(idx + 1) ];
240 } else {
241 return [ src, fill ];
242 }
243 };
244
245 /**
246 * Generate a new request identifier.
247 * @returns {String}
248 */
249 const requestId = () => {
250 return uuid.v1();
251 };
252
253 const nop = () => { /**/ };
254 const nullLogger = {
255 error: nop,
256 warn: nop,
257 info: nop,
258 log: nop,
259 debug: nop,
260 };
261
262 /**
263 * Populates any absent logger levels.
264 * @param {Object} logger
265 */
266 const ensureLoggerLevels = (logger = {}) => {
267 for (const level in nullLogger) {
268 if (! (level in logger)) {
269 logger[level] = nullLogger[level];
270 }
271 }
272 return logger;
273 };
274
275 /**
276 * Merges folded header lines
277 * @param {String[]} lines
278 */
279 const unfoldHeaderLines = (lines) => {
280 const foldedLineRE = /^(\t| +)(.*)$/;
281 if (lines) {
282 lines.reduceRight((_, line, idx) => {
283 const result = foldedLineRE.exec(line);
284 if (result && idx) {
285 const prevIdx = idx - 1;
286 const mergedLine = `${lines[prevIdx]} ${result[2]}`;
287 lines.splice(prevIdx, 2, mergedLine);
288 return mergedLine;
289 }
290 }, null);
291 }
292 return lines;
293 };
294
295 module.exports = {
296 ensureLoggerLevels,
297 fileScope,
298 generateETag,
299 get,
300 handlerLogData,
301 httpStatusCodeClass,
302 isClientCached,
303 mergeDeep,
304 mergeEnum,
305 nop,
306 nullLogger,
307 obscureAuthorizationHeader,
308 pick,
309 requestId,
310 requestLogData,
311 responseLogData,
312 scrubHeaderObject,
313 setOptions,
314 splitFirst,
315 unfoldHeaderLines,
316 };