@contrast/protect 1.6.4 → 1.7.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,8 @@
15
15
 
16
16
  'use strict';
17
17
 
18
- const { BLOCKING_MODES, simpleTraverse } = require('@contrast/common');
18
+ const { BLOCKING_MODES, simpleTraverse, Rule, isString } = require('@contrast/common');
19
19
  const address = require('ipaddr.js');
20
- const { Rule } = require('@contrast/common');
21
20
 
22
21
  //
23
22
  // these rules are not implemented by agent-lib, but are being considered for
@@ -370,8 +369,48 @@ module.exports = function(core) {
370
369
 
371
370
  // was MULTIPART_NAME but maybe we should just call it what it is. it's kind
372
371
  // 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);
372
+ inputAnalysis.handleFileUploadName = function(sourceContext, names) {
373
+ const type = agentLib.InputType.MultipartName;
374
+ const { policy } = sourceContext;
375
+ const resultsList = [];
376
+
377
+ if (policy[Rule.UNSAFE_FILE_UPLOAD] === 'off') return;
378
+
379
+ for (const name of names) {
380
+ if (!isString(name)) {
381
+ logger.debug({ filename: name }, 'handleFileUploadName() was called with non-string');
382
+ return;
383
+ }
384
+
385
+ const items = agentLib.scoreAtom(policy.rulesMask, name, type);
386
+
387
+ if (!items) {
388
+ return;
389
+ }
390
+ for (const item of items) {
391
+ resultsList.push({
392
+ ruleId: item.ruleId,
393
+ inputType: type,
394
+ name: '',
395
+ value: name,
396
+ score: item.score,
397
+ idsList: item.idsList,
398
+ });
399
+ }
400
+ }
401
+
402
+ // something was found, so create "complete" findings.
403
+ const unsafeFilenameFindings = {
404
+ trackRequest: true,
405
+ securityException: undefined,
406
+ resultsList,
407
+ };
408
+
409
+ const block = mergeFindings(sourceContext, unsafeFilenameFindings);
410
+
411
+ if (block) {
412
+ core.protect.throwSecurityException(sourceContext);
413
+ }
375
414
  };
376
415
 
377
416
  inputAnalysis.handleVirtualPatches = function(sourceContext, requestInput) {
@@ -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
  };
@@ -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;
@@ -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.7.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
  }