@contrast/protect 1.6.4 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,7 +39,7 @@ module.exports = function (core) {
39
39
  const isSecurityException = SecurityException.isSecurityException(err);
40
40
 
41
41
  if (isSecurityException && sourceContext && err.output.statusCode !== 403) {
42
- const [ mode, ruleId ] = sourceContext.findings.securityException;
42
+ const [mode, ruleId] = sourceContext.findings.securityException;
43
43
 
44
44
  err.output.statusCode = 403;
45
45
  err.reformat();
package/lib/index.d.ts CHANGED
@@ -1,4 +1,3 @@
1
-
2
1
  /*
3
2
  * Copyright: 2022 Contrast Security, Inc
4
3
  * Contact: support@contrastsecurity.com
@@ -14,7 +13,6 @@
14
13
  * way not consistent with the End User License Agreement.
15
14
  */
16
15
 
17
- // import { Core } from '@contrast/core';
18
16
  import { Logger } from '@contrast/logger';
19
17
  import { Sources } from '@contrast/scopes';
20
18
  import RequireHook from '@contrast/require-hook';
@@ -39,7 +37,7 @@ export class HttpInstrumentation {
39
37
  maxBodySize: number;
40
38
  installed: boolean;
41
39
 
42
- constructor(core: {});
40
+ constructor(core: any);
43
41
 
44
42
  install(): void;
45
43
  uninstall(): void; //NYI
@@ -81,7 +79,7 @@ export interface Protect {
81
79
  handleQueryParams: (sourceContext: ProtectRequestStore, queryParams: { [key: string]: any }) => void,
82
80
  handleUrlParams: (sourceContext: ProtectRequestStore, urlParams: { [key: string]: any }) => void,
83
81
  handleCookies: (sourceContext: ProtectRequestStore, cookies: { [key: string]: any }) => void,
84
- handleFileuploadName: (sourceContext: ProtectRequestStore, name: string) => void, //NYI
82
+ handleFileUploadName: (sourceContext: ProtectRequestStore, names: string[]) => void,
85
83
  librariesInstrumentation: {
86
84
  bodyParser: { install: () => void },
87
85
  coBody: { install: () => void },
@@ -15,9 +15,14 @@
15
15
 
16
16
  'use strict';
17
17
 
18
- const { BLOCKING_MODES, simpleTraverse } = require('@contrast/common');
18
+ const {
19
+ BLOCKING_MODES,
20
+ simpleTraverse,
21
+ Rule,
22
+ isString,
23
+ ProtectRuleMode: { OFF },
24
+ } = require('@contrast/common');
19
25
  const address = require('ipaddr.js');
20
- const { Rule } = require('@contrast/common');
21
26
 
22
27
  //
23
28
  // these rules are not implemented by agent-lib, but are being considered for
@@ -58,6 +63,14 @@ module.exports = function(core) {
58
63
  config,
59
64
  } = core;
60
65
 
66
+ const jsonInputTypes = {
67
+ keyType: agentLib.InputType.JsonKey, inputType: agentLib.InputType.JsonValue
68
+ };
69
+
70
+ const parameterInputTypes = {
71
+ keyType: agentLib.InputType.ParameterKey, inputType: agentLib.InputType.ParameterValue
72
+ };
73
+
61
74
  // all handlers will be invoked with two arguments:
62
75
  // 1) sourceContext object containing:
63
76
  // - reqData, the abstract request object containing only what is needed
@@ -113,8 +126,6 @@ module.exports = function(core) {
113
126
  * @returns {undefined|[String]} undefined to permit else [mode, rule] to block.
114
127
  */
115
128
  inputAnalysis.handleConnect = function handleConnect(sourceContext, connectInputs) {
116
- if (!sourceContext || sourceContext.allowed) return;
117
-
118
129
  const { policy: { rulesMask } } = sourceContext;
119
130
 
120
131
  inputAnalysis.handleVirtualPatches(sourceContext, { URLS: connectInputs.rawUrl, HEADERS: connectInputs.headers });
@@ -123,84 +134,13 @@ module.exports = function(core) {
123
134
  let block = undefined;
124
135
  if (rulesMask !== 0) {
125
136
  const findings = agentLib.scoreRequestConnect(rulesMask, connectInputs, preferWW);
137
+
126
138
  block = mergeFindings(sourceContext, findings);
127
139
  }
128
140
 
129
141
  return block;
130
142
  };
131
143
 
132
- /**
133
- * handleRequestEnd()
134
- *
135
- * Invoked when the request is complete.
136
- *
137
- * @param {Object} sourceContext
138
- */
139
- inputAnalysis.handleRequestEnd = function handleRequestEnd(sourceContext) {
140
- if (!config.protect.probe_analysis.enable) return;
141
-
142
- const { resultsMap } = sourceContext.findings;
143
- const probesRules = [Rule.CMD_INJECTION, Rule.PATH_TRAVERSAL, Rule.SQL_INJECTION, Rule.XXE];
144
- const props = {};
145
-
146
- // Detecting probes
147
- Object.values(resultsMap).forEach(resultsByRuleId => {
148
- resultsByRuleId.forEach((resultByRuleId) => {
149
- const {
150
- ruleId,
151
- blocked,
152
- details,
153
- value,
154
- inputType
155
- } = resultByRuleId;
156
- if (blocked || !blocked && details.length > 0 || !probesRules.some(rule => rule === ruleId)) return;
157
-
158
- const { policy: { rulesMask } } = sourceContext;
159
-
160
- const results = (agentLib.scoreAtom(
161
- rulesMask,
162
- value,
163
- agentLib.InputType[inputType],
164
- {
165
- preferWorthWatching: false
166
- }
167
- ) || []).filter(({ score }) => score >= 90);
168
-
169
- if (!results.length) return;
170
-
171
- results.forEach(result => {
172
- const isAlreadyBlocked = (resultsMap[result.ruleId] || []).some(element =>
173
- element.blocked && element.inputType === inputType && element.value === value
174
- );
175
-
176
- if (isAlreadyBlocked) return;
177
-
178
- const probe = Object.assign({}, resultByRuleId, result, {
179
- mappedId: result.ruleId
180
- });
181
- const key = [probe.ruleId, probe.inputType, ...probe.path, probe.value].join('|');
182
- props[key] = probe;
183
- });
184
- });
185
- });
186
-
187
- Object.values(props).forEach(prop => {
188
- if (!resultsMap[prop.ruleId]) {
189
- resultsMap[prop.ruleId] = [];
190
- }
191
-
192
- resultsMap[prop.ruleId].push(prop);
193
- });
194
- };
195
-
196
- const jsonInputTypes = {
197
- keyType: agentLib.InputType.JsonKey, inputType: agentLib.InputType.JsonValue
198
- };
199
-
200
- const parameterInputTypes = {
201
- keyType: agentLib.InputType.ParameterKey, inputType: agentLib.InputType.ParameterValue
202
- };
203
-
204
144
  /**
205
145
  * handleQueryParams()
206
146
  *
@@ -226,7 +166,6 @@ module.exports = function(core) {
226
166
  return;
227
167
  }
228
168
 
229
-
230
169
  inputAnalysis.handleVirtualPatches(sourceContext, { PARAMETERS: queryParams });
231
170
 
232
171
  const block = commonObjectAnalyzer(sourceContext, queryParams, parameterInputTypes);
@@ -265,10 +204,13 @@ module.exports = function(core) {
265
204
  if (type !== 'Value') {
266
205
  return;
267
206
  }
207
+
268
208
  const items = agentLib.scoreAtom(rulesMask, value, UrlParameter, preferWW);
209
+
269
210
  if (!items) {
270
211
  return;
271
212
  }
213
+
272
214
  for (const item of items) {
273
215
  resultsList.push({
274
216
  ruleId: item.ruleId,
@@ -313,14 +255,15 @@ module.exports = function(core) {
313
255
  if (sourceContext.analyzedCookies) return;
314
256
  sourceContext.analyzedCookies = true;
315
257
 
258
+ inputAnalysis.handleVirtualPatches(sourceContext, { HEADERS: cookies });
259
+
260
+ const { policy: { rulesMask } } = sourceContext;
261
+
316
262
  const cookiesArr = Object.entries(cookies).reduce((acc, [key, value]) => {
317
263
  acc.push(key, value);
318
264
  return acc;
319
265
  }, []);
320
266
 
321
- inputAnalysis.handleVirtualPatches(sourceContext, { HEADERS: cookies });
322
-
323
- const { policy: { rulesMask } } = sourceContext;
324
267
  const cookieFindings = agentLib.scoreRequestConnect(rulesMask, { cookies: cookiesArr }, preferWW);
325
268
 
326
269
  const block = mergeFindings(sourceContext, cookieFindings);
@@ -341,6 +284,7 @@ module.exports = function(core) {
341
284
  */
342
285
  inputAnalysis.handleParsedBody = function(sourceContext, parsedBody) {
343
286
  if (sourceContext.analyzedBody) return;
287
+
344
288
  sourceContext.analyzedBody = true;
345
289
 
346
290
  if (typeof parsedBody !== 'object') {
@@ -359,6 +303,7 @@ module.exports = function(core) {
359
303
  bodyType = 'urlencoded';
360
304
  inputTypes = parameterInputTypes;
361
305
  }
306
+
362
307
  const block = commonObjectAnalyzer(sourceContext, parsedBody, inputTypes);
363
308
 
364
309
  sourceContext.findings.bodyType = bodyType;
@@ -370,8 +315,48 @@ module.exports = function(core) {
370
315
 
371
316
  // was MULTIPART_NAME but maybe we should just call it what it is. it's kind
372
317
  // of a dumb rule anyway. but maybe some code actually uses the name provided.
373
- inputAnalysis.handleFileUploadName = function(sourceContext, name) {
374
- throw new Error('nyi', sourceContext, name);
318
+ inputAnalysis.handleFileUploadName = function(sourceContext, names) {
319
+ const type = agentLib.InputType.MultipartName;
320
+ const { policy } = sourceContext;
321
+ const resultsList = [];
322
+
323
+ if (policy[Rule.UNSAFE_FILE_UPLOAD] === 'off') return;
324
+
325
+ for (const name of names) {
326
+ if (!isString(name)) {
327
+ logger.debug({ filename: name }, 'handleFileUploadName() was called with non-string');
328
+ return;
329
+ }
330
+
331
+ const items = agentLib.scoreAtom(policy.rulesMask, name, type);
332
+
333
+ if (!items) {
334
+ return;
335
+ }
336
+ for (const item of items) {
337
+ resultsList.push({
338
+ ruleId: item.ruleId,
339
+ inputType: type,
340
+ name: '',
341
+ value: name,
342
+ score: item.score,
343
+ idsList: item.idsList,
344
+ });
345
+ }
346
+ }
347
+
348
+ // something was found, so create "complete" findings.
349
+ const unsafeFilenameFindings = {
350
+ trackRequest: true,
351
+ securityException: undefined,
352
+ resultsList,
353
+ };
354
+
355
+ const block = mergeFindings(sourceContext, unsafeFilenameFindings);
356
+
357
+ if (block) {
358
+ core.protect.throwSecurityException(sourceContext);
359
+ }
375
360
  };
376
361
 
377
362
  inputAnalysis.handleVirtualPatches = function(sourceContext, requestInput) {
@@ -439,6 +424,70 @@ module.exports = function(core) {
439
424
  }
440
425
  };
441
426
 
427
+ /**
428
+ * handleRequestEnd()
429
+ *
430
+ * Invoked when the request is complete.
431
+ *
432
+ * @param {Object} sourceContext
433
+ */
434
+ inputAnalysis.handleRequestEnd = function handleRequestEnd(sourceContext) {
435
+ if (!config.protect.probe_analysis.enable || sourceContext.allowed) return;
436
+
437
+ const { resultsMap } = sourceContext.findings;
438
+ const probesRules = [Rule.CMD_INJECTION, Rule.PATH_TRAVERSAL, Rule.SQL_INJECTION, Rule.XXE];
439
+ const props = {};
440
+
441
+ // Detecting probes
442
+ Object.values(resultsMap).forEach(resultsByRuleId => {
443
+ resultsByRuleId.forEach((resultByRuleId) => {
444
+ const {
445
+ ruleId,
446
+ blocked,
447
+ details,
448
+ value,
449
+ inputType
450
+ } = resultByRuleId;
451
+ if (blocked || !blocked && details.length > 0 || !probesRules.some(rule => rule === ruleId)) return;
452
+
453
+ const { policy: { rulesMask } } = sourceContext;
454
+
455
+ const results = (agentLib.scoreAtom(
456
+ rulesMask,
457
+ value,
458
+ agentLib.InputType[inputType],
459
+ {
460
+ preferWorthWatching: false
461
+ }
462
+ ) || []).filter(({ score }) => score >= 90);
463
+
464
+ if (!results.length) return;
465
+
466
+ results.forEach(result => {
467
+ const isAlreadyBlocked = (resultsMap[result.ruleId] || []).some(element =>
468
+ element.blocked && element.inputType === inputType && element.value === value
469
+ );
470
+
471
+ if (isAlreadyBlocked) return;
472
+
473
+ const probe = Object.assign({}, resultByRuleId, result, {
474
+ mappedId: result.ruleId
475
+ });
476
+ const key = [probe.ruleId, probe.inputType, ...probe.path, probe.value].join('|');
477
+ props[key] = probe;
478
+ });
479
+ });
480
+ });
481
+
482
+ Object.values(props).forEach(prop => {
483
+ if (!resultsMap[prop.ruleId]) {
484
+ resultsMap[prop.ruleId] = [];
485
+ }
486
+
487
+ resultsMap[prop.ruleId].push(prop);
488
+ });
489
+ };
490
+
442
491
  /**
443
492
  * commonObjectAnalyzer() walks an object supplied by the end-user and checks
444
493
  * it for vulnerabilities.
@@ -493,7 +542,9 @@ module.exports = function(core) {
493
542
  } else {
494
543
  itemType = inputType;
495
544
  }
545
+
496
546
  let items = agentLib.scoreAtom(rulesMask, value, itemType, preferWW);
547
+
497
548
  if (!items && !isMongoQueryType) {
498
549
  return;
499
550
  }
@@ -589,6 +640,66 @@ module.exports = function(core) {
589
640
  }
590
641
  };
591
642
 
643
+ /**
644
+ * Reads the source context's policy and compares to result item to check whether to ignore it.
645
+ * @param {ProtectMessage} sourceContext
646
+ * @param {Result} result
647
+ * @returns {boolean} whether result should be excluded
648
+ */
649
+ function isResultExcluded(sourceContext, result) {
650
+ const { policy: { exclusions } } = sourceContext;
651
+ const { ruleId, path, inputType, value } = result;
652
+ const inputName = path ? path[path.length - 1] : null;
653
+
654
+ let checkCookiesInHeader = false;
655
+ let inputExclusions;
656
+ switch (inputType) {
657
+ case 'JsonKey':
658
+ case 'JsonValue':
659
+ case 'MultipartName': {
660
+ return exclusions.ignoreBody || exclusions.bodyPolicy?.[ruleId] === OFF;
661
+ }
662
+ case 'ParameterKey':
663
+ case 'ParameterValue': {
664
+ const qsExcluded = exclusions.ignoreQuerystring || exclusions.querystringPolicy?.[ruleId] === OFF;
665
+ if (qsExcluded) return true;
666
+ inputExclusions = exclusions.parameter;
667
+ break;
668
+ }
669
+ case 'CookieValue': {
670
+ inputExclusions = exclusions.cookie;
671
+ break;
672
+ }
673
+ case 'HeaderKey':
674
+ case 'HeaderValue': {
675
+ if (path?.[0]?.toLowerCase() === 'cookie') {
676
+ inputExclusions = exclusions.cookie;
677
+ checkCookiesInHeader = true;
678
+ } else {
679
+ inputExclusions = exclusions.header;
680
+ }
681
+ break;
682
+ }
683
+ }
684
+
685
+ if (!inputName || !inputExclusions) return false;
686
+
687
+ for (const excl of inputExclusions) {
688
+ let nameCheck = false;
689
+ if (checkCookiesInHeader) {
690
+ nameCheck = excl.checkCookiesInHeader(value);
691
+ } else {
692
+ nameCheck = excl.matchesInputName(inputName);
693
+ }
694
+ if (!nameCheck) continue;
695
+ if (!excl.policy || excl.policy[ruleId] === OFF) {
696
+ return true;
697
+ }
698
+ }
699
+
700
+ return false;
701
+ }
702
+
592
703
  /**
593
704
  * merge new findings into the existing findings
594
705
  *
@@ -602,6 +713,11 @@ function mergeFindings(sourceContext, newFindings) {
602
713
  if (!newFindings.trackRequest) {
603
714
  return findings.securityException;
604
715
  }
716
+
717
+ newFindings.resultsList = newFindings.resultsList.filter(
718
+ (result) => !isResultExcluded(sourceContext, result)
719
+ );
720
+
605
721
  normalizeFindings(policy, newFindings);
606
722
 
607
723
  findings.trackRequest = findings.trackRequest || newFindings.trackRequest;
@@ -0,0 +1,55 @@
1
+ /*
2
+ * Copyright: 2022 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const { patchType } = require('../constants');
19
+
20
+ module.exports = (core) => {
21
+ const {
22
+ depHooks,
23
+ patcher,
24
+ protect,
25
+ protect: { inputAnalysis },
26
+ } = core;
27
+
28
+ // Patch `busboy`
29
+ function install() {
30
+ depHooks.resolve({ name: 'busboy' }, (busboy) => {
31
+ patcher.patch(busboy.prototype, 'emit', {
32
+ name: 'busboy.prototype.emit',
33
+ patchType,
34
+ pre({ args }) {
35
+ const sourceContext = protect.getSourceContext('busboy');
36
+
37
+ if (sourceContext) {
38
+ const [eventName, , , fileName] = args;
39
+
40
+ if (eventName === 'file' && fileName) {
41
+ inputAnalysis.handleFileUploadName(sourceContext, [fileName]);
42
+ }
43
+ }
44
+ }
45
+ });
46
+ });
47
+ }
48
+
49
+ const busboy1Instrumentation = inputAnalysis.busboy1Instrumentation = {
50
+ install
51
+ };
52
+
53
+ return busboy1Instrumentation;
54
+ };
55
+
@@ -21,7 +21,6 @@ module.exports = (core) => {
21
21
  const {
22
22
  depHooks,
23
23
  patcher,
24
- logger,
25
24
  protect,
26
25
  protect: { inputAnalysis },
27
26
  } = core;
@@ -33,30 +32,40 @@ module.exports = (core) => {
33
32
  name: 'Formidable.IncomingForm.prototype.parse',
34
33
  patchType,
35
34
  pre(data) {
36
- const origCb = data.args[1];
35
+ const sourceContext = protect.getSourceContext('formidable');
37
36
 
38
- function hookedCb(...cbArgs) {
39
- const sourceContext = protect.getSourceContext('formidable');
37
+ if (sourceContext) {
38
+ const origCb = data.args[1];
40
39
 
41
- const [, fields, files] = cbArgs;
40
+ data.args[1] = function hookedCb(...cbArgs) {
41
+ const [, fields, files] = cbArgs;
42
42
 
43
- if (sourceContext) {
44
43
  if (fields) {
45
44
  sourceContext.parsedBody = fields;
46
45
  inputAnalysis.handleParsedBody(sourceContext, fields);
47
46
  }
47
+
48
48
  if (files) {
49
- logger.debug('Check for vulnerable filename upload nyi');
50
- // TODO: NODE-2601
49
+ const filenames = [];
50
+
51
+ for (const file of Object.values(files)) {
52
+ if (!Array.isArray(file)) {
53
+ if (file.name) filenames.push(file.name);
54
+ } else {
55
+ for (const multiPart of file) {
56
+ if (multiPart.name) filenames.push(multiPart.name);
57
+ }
58
+ }
59
+ }
60
+
61
+ inputAnalysis.handleFileUploadName(sourceContext, filenames);
51
62
  }
52
- }
53
63
 
54
- if (origCb && typeof origCb === 'function') {
55
- origCb.apply(this, cbArgs);
56
- }
64
+ if (origCb && typeof origCb === 'function') {
65
+ origCb.apply(this, cbArgs);
66
+ }
67
+ };
57
68
  }
58
-
59
- data.args[1] = hookedCb;
60
69
  }
61
70
  });
62
71
 
@@ -39,6 +39,10 @@ module.exports = (core) => {
39
39
  { name: '@hapi/hapi', version: '>=18 <21' },
40
40
  registerServerHandler
41
41
  );
42
+ depHooks.resolve(
43
+ { name: '@hapi/pez' },
44
+ registerMultipartHandler
45
+ );
42
46
  }
43
47
 
44
48
  const registerServerHandler = (hapi) => {
@@ -98,6 +102,27 @@ module.exports = (core) => {
98
102
  });
99
103
  };
100
104
 
105
+ const registerMultipartHandler = (Pez) => {
106
+ patcher.patch(Pez.Dispenser.prototype, 'emit', {
107
+ name: 'Pez.Dispenser.prototype.emit',
108
+ patchType,
109
+ pre: ({ args }) => {
110
+ const sourceContext = protect.getSourceContext('hapi/pez');
111
+
112
+ if (sourceContext) {
113
+ const [eventName, part] = args;
114
+ // note: this will emit multiple file upload events during larger files.
115
+ // given the domain of this as being part of unsafe-file-upload (which is BAP)
116
+ // this isn't much of a concern.
117
+
118
+ if (eventName === 'part' && part.filename) {
119
+ inputAnalysis.handleFileUploadName(sourceContext, [part.filename]);
120
+ }
121
+ }
122
+ }
123
+ });
124
+ };
125
+
101
126
  const hapiInstrumentation = inputAnalysis.hapiInstrumentation = {
102
127
  install
103
128
  };
@@ -176,8 +176,13 @@ class HttpInstrumentation {
176
176
  setImmediate(() => method.call(instance, ...args));
177
177
  return;
178
178
  }
179
-
180
179
  store.protect = this.makeSourceContext(req, res);
180
+
181
+ if (store.protect.allowed) {
182
+ setImmediate(() => method.call(instance, ...args));
183
+ return;
184
+ }
185
+
181
186
  const { reqData } = store.protect;
182
187
 
183
188
  res.on('finish', () => {
@@ -48,6 +48,7 @@ module.exports = (core) => {
48
48
 
49
49
  function contrastNext(origErr) {
50
50
  let securityException;
51
+ const filenames = [];
51
52
 
52
53
  if (req.body) {
53
54
  sourceContext.parsedBody = req.body;
@@ -63,9 +64,26 @@ module.exports = (core) => {
63
64
  }
64
65
  }
65
66
 
66
- if (req.file || req.files) {
67
- logger.debug('Check for vulnerable filename upload nyi');
68
- // TODO: NODE-2601
67
+ if (req.file) {
68
+ filenames.push(req.file.originalname);
69
+ }
70
+
71
+ if (req.files) {
72
+ for (const file of Object.values(req.files)) {
73
+ filenames.push(file.originalname);
74
+ }
75
+ }
76
+
77
+ if (filenames.length) {
78
+ try {
79
+ inputAnalysis.handleFileUploadName(sourceContext, filenames);
80
+ } catch (err) {
81
+ if (isSecurityException(err)) {
82
+ securityException = err;
83
+ } else {
84
+ logger.error({ err }, 'Unexpected error during input analysis');
85
+ }
86
+ }
69
87
  }
70
88
 
71
89
  const error = securityException || origErr;
@@ -26,7 +26,7 @@ module.exports = (core) => {
26
26
  const virtualPatchesEvaluators = inputAnalysis.virtualPatchesEvaluators = [];
27
27
 
28
28
  messages.on(Event.SERVER_SETTINGS_UPDATE, (serverUpdate) => {
29
- const virtualPatches = serverUpdate.settings?.defend.virtualPatches;
29
+ const virtualPatches = serverUpdate.settings?.defend?.virtualPatches;
30
30
  if (virtualPatches) {
31
31
  buildVPEvaluators(virtualPatches, virtualPatchesEvaluators);
32
32
  }
@@ -16,7 +16,9 @@
16
16
  'use strict';
17
17
 
18
18
  module.exports = function(core) {
19
- const { protect } = core;
19
+ const {
20
+ protect: { getPolicy }
21
+ } = core;
20
22
 
21
23
  function makeSourceContext(req, res) {
22
24
  // make the abstract request. it is an abstraction of a request that
@@ -27,8 +29,10 @@ module.exports = function(core) {
27
29
  // or res objects so that all data coupling is all defined here.
28
30
 
29
31
  // separate path and search params
30
- const ix = req.url.indexOf('?');
32
+
31
33
  let uriPath, queries;
34
+ const ix = req.url.indexOf('?');
35
+
32
36
  if (ix >= 0) {
33
37
  uriPath = req.url.slice(0, ix);
34
38
  queries = req.url.slice(ix + 1);
@@ -36,6 +40,14 @@ module.exports = function(core) {
36
40
  uriPath = req.url;
37
41
  queries = '';
38
42
  }
43
+
44
+ const policy = getPolicy({ uriPath });
45
+
46
+ // URL exclusions can disable all rules
47
+ if (!policy) {
48
+ return { allowed: true };
49
+ }
50
+
39
51
  // lowercase header keys and capture content-type
40
52
  let contentType = '';
41
53
  const headers = Array(req.rawHeaders.length);
@@ -80,7 +92,7 @@ module.exports = function(core) {
80
92
  // block closure captures res so it isn't exposed to beyond here
81
93
  block: core.protect.makeResponseBlocker(res),
82
94
 
83
- policy: protect.getPolicy(),
95
+ policy,
84
96
 
85
97
  exclusions: [],
86
98
  virtualPatchesEvaluators: [],
package/lib/policy.js CHANGED
@@ -27,14 +27,72 @@ const {
27
27
  Event: { SERVER_SETTINGS_UPDATE },
28
28
  } = require('@contrast/common');
29
29
 
30
-
31
30
  module.exports = function(core) {
32
- const { config, logger, messages, protect } = core;
33
- const policy = protect.policy = {};
31
+ const {
32
+ config,
33
+ logger,
34
+ messages,
35
+ protect,
36
+ protect: { agentLib }
37
+ } = core;
38
+
39
+ const compiled = {
40
+ url: [],
41
+ querystring: [],
42
+ header: [],
43
+ body: [],
44
+ cookie: [],
45
+ parameter: [],
46
+ };
47
+
48
+ const policy = protect.policy = {
49
+ exclusions: compiled
50
+ };
51
+
52
+ function regExpCheck(str) {
53
+ return str.indexOf('*') > 0 ||
54
+ str.indexOf('.') > 0 ||
55
+ str.indexOf('+') > 0 ||
56
+ str.indexOf('?') > 0 ||
57
+ str.indexOf('\\') > 0;
58
+ }
59
+
60
+ function buildUriPathRegExp(urls) {
61
+ let regExpNeeded = false;
62
+ for (const url of urls) {
63
+ if (regExpCheck(url)) {
64
+ regExpNeeded = true;
65
+ }
66
+ }
67
+ if (regExpNeeded) {
68
+ const rx = new RegExp(`^${urls.join('|')}$`);
69
+
70
+ return (uriPath) => rx ? rx.test(uriPath) : false;
71
+ }
72
+
73
+ return (uriPath) => urls.some((url) => url === uriPath);
74
+ }
75
+
76
+ function createUriPathMatcher(urls) {
77
+ if (urls.length) {
78
+ return buildUriPathRegExp(urls);
79
+ } else {
80
+ return () => true;
81
+ }
82
+ }
83
+
84
+ function createInputNameMatcher(dtmInputName) {
85
+ if (regExpCheck(dtmInputName)) {
86
+ const rx = new RegExp(`^${dtmInputName}$`);
87
+ return (inputName) => rx ? rx.test(inputName) : false;
88
+ }
89
+
90
+ return (inputName) => inputName === dtmInputName;
91
+ }
34
92
 
35
93
  function getModeFromConfig(ruleId) {
36
94
  if (config.protect.disabled_rules.includes(ruleId)) {
37
- return 'off';
95
+ return OFF;
38
96
  }
39
97
  return config.protect.rules?.[ruleId]?.mode;
40
98
  }
@@ -84,11 +142,20 @@ module.exports = function(core) {
84
142
  */
85
143
  function updateRulesMask() {
86
144
  let rulesMask = 0;
87
- for (const [ruleId, mode] of Object.entries(policy)) {
145
+
146
+ for (const entry of Object.entries(policy)) {
147
+ let [ruleId] = entry;
148
+ const [, mode] = entry;
149
+
150
+ if (ruleId === 'nosql-injection') {
151
+ ruleId = 'nosql-injection-mongo';
152
+ }
153
+
88
154
  if (protect.agentLib.RuleType[ruleId] && mode !== OFF) {
89
155
  rulesMask = rulesMask | protect.agentLib.RuleType[ruleId];
90
156
  }
91
157
  }
158
+
92
159
  policy.rulesMask = rulesMask;
93
160
  }
94
161
 
@@ -96,13 +163,83 @@ module.exports = function(core) {
96
163
  * This gets called by protect.makeSourceContext(). We return copy of policy to avoid
97
164
  * inconsistent behavior if policy is updated during request handling.
98
165
  */
99
- function getPolicy() {
100
- return { ...policy };
101
- }
166
+ function getPolicy({ uriPath } = {}) {
167
+ const requestPolicy = {
168
+ exclusions: {
169
+ ignoreQuerystring: false,
170
+ querystringPolicy: null,
171
+ ignoreBody: false,
172
+ bodyPolicy: null,
173
+ header: [],
174
+ cookie: [],
175
+ parameter: [],
176
+ },
177
+ rulesMask: policy.rulesMask,
178
+ };
102
179
 
103
- initPolicy();
180
+ for (const ruleId of Object.values(Rule)) {
181
+ requestPolicy[ruleId] = policy[ruleId];
182
+ }
104
183
 
105
- messages.on(SERVER_SETTINGS_UPDATE, (remoteSettings) => {
184
+ // handle exclusions
185
+ for (const [inputType, exclusions] of Object.entries(compiled)) {
186
+ for (const e of exclusions) {
187
+ if (!e.matchesUriPath(uriPath)) continue;
188
+
189
+ // url exclusions
190
+ if (inputType === 'url') {
191
+ // if applies to all rules, there is no policy for the request i.e. disable protect
192
+ if (!e.policy) {
193
+ return null;
194
+ }
195
+
196
+ // merge exclusion's policy into the request's policy
197
+ for (const key of Object.keys(e.policy)) {
198
+ const value = e.policy[key];
199
+ if (key === 'rulesMask') {
200
+ // this is how to disable rules bitwise
201
+ requestPolicy.rulesMask = requestPolicy.rulesMask & ~value;
202
+ } else {
203
+ requestPolicy[key] = value;
204
+ }
205
+ }
206
+ } else if (inputType === 'querystring') {
207
+ if (!e.policy) {
208
+ requestPolicy.exclusions.ignoreQuerystring = true;
209
+ } else {
210
+ // merge exclusion's policy into the querystring's policy
211
+ requestPolicy.exclusions.querystringPolicy = requestPolicy.exclusions.querystringPolicy || {};
212
+ for (const key of Object.keys(e.policy)) {
213
+ const value = e.policy[key];
214
+ if (key !== 'rulesMask') {
215
+ requestPolicy.exclusions.querystringPolicy[key] = value;
216
+ }
217
+ }
218
+ }
219
+ } else if (inputType === 'body') {
220
+ if (!e.policy) {
221
+ requestPolicy.exclusions.ignoreBody = true;
222
+ } else {
223
+ // merge exclusion's policy into the querystring's policy
224
+ requestPolicy.exclusions.bodyPolicy = requestPolicy.exclusions.bodyPolicy || {};
225
+ for (const key of Object.keys(e.policy)) {
226
+ const value = e.policy[key];
227
+ if (key !== 'rulesMask') {
228
+ requestPolicy.exclusions.bodyPolicy[key] = value;
229
+ }
230
+ }
231
+ }
232
+ } else {
233
+ // copy matching input exclusions into request policy
234
+ requestPolicy.exclusions[inputType].push(e);
235
+ }
236
+ }
237
+ }
238
+
239
+ return requestPolicy;
240
+ }
241
+
242
+ function updateGlobalPolicy(remoteSettings) {
106
243
  let update;
107
244
 
108
245
  const protectionRules = remoteSettings?.settings?.defend?.protectionRules;
@@ -128,7 +265,79 @@ module.exports = function(core) {
128
265
  updateRulesMask();
129
266
  logger.info({ policy: protect.policy }, `protect policy updated from ${update}`);
130
267
  }
268
+ }
269
+
270
+ function updateExclusions(serverUpdate) {
271
+ const exclusions = [
272
+ ...(serverUpdate.settings?.exceptions?.inputExceptions || []),
273
+ ...(serverUpdate.settings?.exceptions?.urlExceptions || [])
274
+ ].filter((exclusion) => exclusion.modes.includes('defend'));
275
+
276
+ if (!exclusions.length) return;
277
+
278
+ for (const exclusionDtm of exclusions) {
279
+ exclusionDtm.inputType = exclusionDtm.inputType || 'URL';
280
+
281
+ const { name, rules, inputName, urls, inputType } = exclusionDtm;
282
+ const key = inputType.toLowerCase();
283
+
284
+ if (!compiled[key]) continue;
285
+
286
+ try {
287
+ const e = { name };
288
+ e.matchesUriPath = createUriPathMatcher(urls);
289
+
290
+ if (inputName) {
291
+ e.matchesInputName = createInputNameMatcher(inputName);
292
+ }
293
+
294
+ if (rules.length) {
295
+ let rulesMask = 0;
296
+ const exclusionPolicy = {};
297
+
298
+ for (let ruleId of rules) {
299
+ // todo: this doesn't seem to make a difference?
300
+ if (ruleId === 'nosql-injection') {
301
+ ruleId = 'nosql-injection-mongo';
302
+ }
303
+
304
+ if (agentLib.RuleType[ruleId]) {
305
+ Object.assign(exclusionPolicy, { [ruleId]: OFF });
306
+ if (inputType === 'URL') {
307
+ rulesMask = rulesMask | agentLib.RuleType[ruleId];
308
+ exclusionPolicy.rulesMask = rulesMask;
309
+ }
310
+ }
311
+ }
312
+
313
+ e.policy = exclusionPolicy;
314
+ }
315
+ if (key === 'cookie') {
316
+ e.checkCookieInHeader = (cookieHeader) => {
317
+ for (const cookiePair of cookieHeader.split(';')) {
318
+ const cookieKey = cookiePair.split('=')[0];
319
+ if (e.matchesInputName(cookieKey)) {
320
+ return true;
321
+ }
322
+ }
323
+
324
+ return false;
325
+ };
326
+ }
327
+
328
+ compiled[key].push(e);
329
+ } catch (err) {
330
+ logger.error({ err, exclusionDtm }, 'failed to process exclusion');
331
+ }
332
+ }
333
+ }
334
+
335
+ messages.on(SERVER_SETTINGS_UPDATE, (msg) => {
336
+ updateGlobalPolicy(msg);
337
+ updateExclusions(msg);
131
338
  });
132
339
 
340
+ initPolicy();
341
+
133
342
  return protect.getPolicy = getPolicy;
134
343
  };
@@ -0,0 +1,20 @@
1
+ /*
2
+ * Copyright: 2022 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ module.exports = {
19
+ patchType: 'protect-semantic-analysis',
20
+ };
@@ -20,10 +20,13 @@ const {
20
20
  BLOCKING_MODES,
21
21
  ProtectRuleMode: { OFF },
22
22
  InputType,
23
- isString,
24
23
  simpleTraverse
25
24
  } = require('@contrast/common');
26
25
 
26
+ const {
27
+ findExternalEntities,
28
+ } = require('./utils/xml-analysis');
29
+
27
30
  const SINK_EXPLOIT_PATTERN_START = /(?:^|\\|\/)(?:sh|bash|zsh|ksh|tcsh|csh|fish|cmd)/;
28
31
  const stripWhiteSpace = (str) => str.replace(/\s/g, '');
29
32
 
@@ -103,6 +106,18 @@ module.exports = function(core) {
103
106
  }
104
107
  };
105
108
 
109
+ semanticAnalysis.handleXXE = function (sourceContext, sinkContext) {
110
+ const mode = sourceContext.policy[Rule.XXE];
111
+ if (mode == OFF) return;
112
+
113
+ const findings = findExternalEntities(sinkContext.value);
114
+ if (findings.entities.length) {
115
+ handleResult(sourceContext, sinkContext, Rule.XXE, mode, {
116
+ findings,
117
+ });
118
+ }
119
+ };
120
+
106
121
  return semanticAnalysis;
107
122
  };
108
123
 
@@ -127,7 +142,7 @@ function findBackdoorInjection(sourceContext, command) {
127
142
  };
128
143
 
129
144
  let found = false;
130
- for (let inputType in valuesOfInterest) {
145
+ for (const inputType in valuesOfInterest) {
131
146
  const values = valuesOfInterest[inputType];
132
147
 
133
148
  if (values && Object.keys(values).length) {
@@ -139,7 +154,7 @@ function findBackdoorInjection(sourceContext, command) {
139
154
  ) {
140
155
  let key;
141
156
  if (inputType === InputType.HEADER) {
142
- key = obj[path[0] - 1]
157
+ key = obj[path[0] - 1];
143
158
  } else {
144
159
  key = path[path.length - 1];
145
160
  }
@@ -15,6 +15,8 @@
15
15
 
16
16
  'use strict';
17
17
 
18
+ const { installChildComponentsSync } = require('@contrast/common');
19
+
18
20
  /**
19
21
  * SEMANTIC ANALYSIS is a STAGE of Protect.
20
22
  * The information about it can be found under some of the separate rules we support for this stage:
@@ -31,6 +33,13 @@ module.exports = function(core) {
31
33
  // load the interfaces that will be used by input tracing instrumentation
32
34
  require('./handlers')(core);
33
35
 
36
+ // instrumentation
37
+ require('./install/libxmljs')(core);
38
+
39
+ semanticAnalysis.install = function() {
40
+ installChildComponentsSync(semanticAnalysis);
41
+ };
42
+
34
43
  // There is no `.install()` method as this STAGE does not introduce side effects on its own,
35
44
  // it uses the instrumentation that's already in place for INPUT TRACING.
36
45
 
@@ -0,0 +1,82 @@
1
+ /*
2
+ * Copyright: 2022 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const { isString } = require('@contrast/common');
19
+ const SecurityException = require('../../security-exception');
20
+ const { patchType } = require('../constants');
21
+
22
+ module.exports = function(core) {
23
+ const {
24
+ depHooks,
25
+ patcher,
26
+ logger,
27
+ protect: { semanticAnalysis },
28
+ protect,
29
+ captureStacktrace
30
+ } = core;
31
+
32
+ function install() {
33
+ depHooks.resolve({ name: 'libxmljs' }, hookLibXml);
34
+ // libxmljs2 is a fork of libxml that has identical signatures.
35
+ // the only difference is that libxmljs2 is used by juice-shop and thus
36
+ // we're missing sinks in one of our most important sample apps.
37
+ depHooks.resolve({ name: 'libxmljs2' }, hookLibXml);
38
+ }
39
+
40
+ function hookLibXml(libxmljs) {
41
+ patcher.patch(libxmljs, 'parseXmlString', {
42
+ name: 'libxmljs.parseXmlString',
43
+ patchType,
44
+ pre: preParseXmlMethod
45
+ });
46
+
47
+ patcher.patch(libxmljs, 'parseXml', {
48
+ name: 'libxmljs.parseXml',
49
+ patchType,
50
+ pre: preParseXmlMethod
51
+ });
52
+ }
53
+
54
+ function preParseXmlMethod({ args, hooked, orig }) {
55
+ const sourceContext = protect.getSourceContext('libxmljs.parseXmlString');
56
+ const value = args[0];
57
+
58
+ // If noent isn't set to true than libxml won't load
59
+ // external entities, so this isn't vulnerable
60
+ // see: https://help.semmle.com/wiki/display/JS/XML+external+entity+expansion
61
+ if (!sourceContext || !value || !isString(value) || !args[1].noent) return;
62
+
63
+ const sinkContext = captureStacktrace(
64
+ { name: 'libxmljs.parseXmlString', value },
65
+ { constructorOpt: hooked, prependFrames: [orig] }
66
+ );
67
+
68
+ try {
69
+ semanticAnalysis.handleXXE(sourceContext, sinkContext);
70
+ } catch (err) {
71
+ if (SecurityException.isSecurityException(err)) {
72
+ throw err;
73
+ }
74
+
75
+ logger.error({ err }, 'Unexpected error during semantic analysis');
76
+ }
77
+ }
78
+
79
+ const libxmljs = semanticAnalysis.libxmljs = { install };
80
+
81
+ return libxmljs;
82
+ };
@@ -0,0 +1,107 @@
1
+ /*
2
+ * Copyright: 2022 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+ 'use strict';
16
+
17
+ const PROTOCOLS = {
18
+ FTP: 'FTP',
19
+ HTTP: 'HTTP',
20
+ HTTPS: 'HTTPS',
21
+ TCP: 'TCP'
22
+ };
23
+
24
+ const FTP = `${PROTOCOLS.FTP.toLowerCase()}:`;
25
+ const HTTP = `${PROTOCOLS.HTTP.toLowerCase()}:`;
26
+ const HTTPS = `${PROTOCOLS.HTTPS.toLowerCase()}:`;
27
+ const DTD_EXTENSION = '.dtd';
28
+ const FILE_START = 'file:';
29
+ const GOPHER_START = 'gopher:';
30
+ const JAR_START = 'jar:';
31
+ const DOT = '.';
32
+ const FORWARD_SLASH = '/';
33
+ const UP_DIR_LINUX = '../';
34
+ const UP_DIR_WIN = '..\\';
35
+ const ENTITY_TYPES = {
36
+ SYSTEM: 'SYSTEM',
37
+ PUBLIC: 'PUBLIC'
38
+ };
39
+
40
+ // We only use this against lowercase strings; removed A-Z for speed
41
+ const FILE_PATTERN_WINDOWS = /^[\\\\]*[a-z]{1,3}:.*/;
42
+
43
+ const entityRegex = /<!ENTITY\s+(?<name>[a-zA-Z0-f]+)\s+(?<type>SYSTEM|PUBLIC)\s+"(?<uri1>.*?)"\s*("(?<uri2>.*?)"\s*)?>/g;
44
+
45
+ // Helper Functions
46
+ const indicators = [
47
+ FTP,
48
+ FILE_START,
49
+ JAR_START,
50
+ GOPHER_START,
51
+ FORWARD_SLASH,
52
+ DOT,
53
+ UP_DIR_LINUX,
54
+ UP_DIR_WIN,
55
+ ];
56
+
57
+ function isExternalEntity(str) {
58
+ if (!str) return false;
59
+
60
+ if (str.startsWith(HTTP) || str.startsWith(HTTPS)) {
61
+ if (!str.endsWith(DTD_EXTENSION)) return true;
62
+ }
63
+
64
+ for (const val of indicators) {
65
+ if (str.startsWith(val)) return true;
66
+ }
67
+
68
+ return FILE_PATTERN_WINDOWS.test(str);
69
+ }
70
+
71
+ /**
72
+ * @param {String} xml
73
+ */
74
+ module.exports.findExternalEntities = function(xml = '') {
75
+ const entities = [];
76
+ let match;
77
+
78
+ while ((match = entityRegex.exec(xml))) {
79
+ const {
80
+ groups: { type, uri1, uri2 }
81
+ } = match;
82
+
83
+ let uri;
84
+ if (type === ENTITY_TYPES.SYSTEM && isExternalEntity(uri1)) {
85
+ uri = uri1;
86
+ } else if (type === ENTITY_TYPES.PUBLIC && isExternalEntity(uri2)) {
87
+ uri = uri2;
88
+ }
89
+
90
+ uri && entities.push({
91
+ start: match.index,
92
+ finish: match.index + match[0].length,
93
+ type,
94
+ uri,
95
+ });
96
+ }
97
+
98
+ const len = entities.length;
99
+
100
+ return {
101
+ entities,
102
+ prolog: len && xml.substr(0, entities[len - 1].finish) || null
103
+ };
104
+ };
105
+
106
+ module.exports.ENTITY_TYPES = ENTITY_TYPES;
107
+ module.exports.isExternalEntity = isExternalEntity;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/protect",
3
- "version": "1.6.4",
3
+ "version": "1.8.0",
4
4
  "description": "Contrast service providing framework-agnostic Protect support",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Contrast Security <nodejs@contrastsecurity.com> (https://www.contrastsecurity.com)",
@@ -17,14 +17,11 @@
17
17
  "test": "../scripts/test.sh"
18
18
  },
19
19
  "dependencies": {
20
- "@babel/template": "^7.16.7",
21
- "@babel/types": "^7.16.8",
22
20
  "@contrast/agent-lib": "^5.1.0",
23
- "@contrast/common": "1.1.3",
24
- "@contrast/core": "1.6.1",
25
- "@contrast/esm-hooks": "1.2.1",
26
- "@contrast/scopes": "1.1.2",
27
- "builtin-modules": "^3.2.0",
21
+ "@contrast/common": "1.1.4",
22
+ "@contrast/core": "1.7.0",
23
+ "@contrast/esm-hooks": "1.3.0",
24
+ "@contrast/scopes": "1.2.0",
28
25
  "ipaddr.js": "^2.0.1",
29
26
  "semver": "^7.3.7"
30
27
  }