@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.
- package/lib/error-handlers/install/hapi.js +1 -1
- package/lib/index.d.ts +2 -4
- package/lib/input-analysis/handlers.js +198 -82
- package/lib/input-analysis/install/busboy1.js +55 -0
- package/lib/input-analysis/install/formidable1.js +23 -14
- package/lib/input-analysis/install/hapi.js +25 -0
- package/lib/input-analysis/install/http.js +6 -1
- package/lib/input-analysis/install/multer1.js +21 -3
- package/lib/input-analysis/virtual-patches.js +1 -1
- package/lib/make-source-context.js +15 -3
- package/lib/policy.js +219 -10
- package/lib/semantic-analysis/constants.js +20 -0
- package/lib/semantic-analysis/handlers.js +18 -3
- package/lib/semantic-analysis/index.js +9 -0
- package/lib/semantic-analysis/install/libxmljs.js +82 -0
- package/lib/semantic-analysis/utils/xml-analysis.js +107 -0
- package/package.json +5 -8
|
@@ -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 [
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
374
|
-
|
|
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
|
|
35
|
+
const sourceContext = protect.getSourceContext('formidable');
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
const
|
|
37
|
+
if (sourceContext) {
|
|
38
|
+
const origCb = data.args[1];
|
|
40
39
|
|
|
41
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 {
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
+
for (const ruleId of Object.values(Rule)) {
|
|
181
|
+
requestPolicy[ruleId] = policy[ruleId];
|
|
182
|
+
}
|
|
104
183
|
|
|
105
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
24
|
-
"@contrast/core": "1.
|
|
25
|
-
"@contrast/esm-hooks": "1.
|
|
26
|
-
"@contrast/scopes": "1.
|
|
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
|
}
|