Skip to content

Commit 2835ba1

Browse files
authored
Merge pull request #7386 from Countly/hook-valid
[SER-2844] Implement domain/ip address validation for hooks with http effect
2 parents 0aec524 + fc749f3 commit 2835ba1

13 files changed

Lines changed: 463 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## Version 25.03.X
2+
Fixes:
3+
- [hooks] Implement domain/ip address validation for hooks with http effect
4+
15
## Version 25.03.39
26
Fixes:
37
- [core] Fixed replaceDatabaseString incorrectly replacing "countly" in the MongoDB username when it appears in the connection URL

plugins/hooks/api/api.js

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const log = common.log('hooks:api');
99
const _ = require('lodash');
1010
const utils = require('./utils');
1111
const rights = require('../../../api/utils/rights');
12+
const ssrfProtection = require('./ssrf-protection');
1213

1314
const FEATURE_NAME = 'hooks';
1415

@@ -232,7 +233,7 @@ const CheckEffectProperties = function(effect) {
232233
//todo: add more validation for effect types
233234
if (effect) {
234235
if (effect.type === "HTTPEffect") {
235-
rules.url = { 'required': true, 'type': 'URL', 'regex': '^(?!.*(?:localhost|127\\.0\\.0\\.1|\\[::1\\])).*(?:https?|ftp):\\/\\/(?:[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)+|\\[(?:[a-fA-F0-9]{1,4}:){7}[a-fA-F0-9]{1,4}\\])(?::\\d{1,5})?(?:\\/\\S*)?$' };
236+
rules.url = { 'required': true, 'type': 'URL' };
236237
rules.headers = { 'required': false, 'type': 'Object' };
237238
}
238239
}
@@ -273,7 +274,7 @@ plugins.register("/permissions/features", function(ob) {
273274
plugins.register("/i/hook/save", function(ob) {
274275
let paramsInstance = ob.params;
275276

276-
validateCreate(ob.params, FEATURE_NAME, function(params) {
277+
validateCreate(ob.params, FEATURE_NAME, async function(params) {
277278
let hookConfig = params.qstring.hook_config;
278279
if (!hookConfig) {
279280
common.returnMessage(params, 400, 'Invalid hookConfig');
@@ -290,9 +291,12 @@ plugins.register("/i/hook/save", function(ob) {
290291
return true;
291292
}
292293

293-
if (hookConfig.effects && !validateEffects(hookConfig.effects)) {
294-
common.returnMessage(params, 400, 'Invalid configuration for effects');
295-
return true;
294+
if (hookConfig.effects) {
295+
const effectValidation = await validateEffects(hookConfig.effects);
296+
if (!effectValidation.valid) {
297+
common.returnMessage(params, 400, effectValidation.error || 'Invalid configuration for effects');
298+
return true;
299+
}
296300
}
297301

298302
if (hookConfig._id) {
@@ -367,20 +371,25 @@ plugins.register("/i/hook/save", function(ob) {
367371

368372
/***
369373
* @param {array} effects - array of effects
370-
* @returns {boolean} isValid - true if all effects are valid
371374
*/
372-
function validateEffects(effects) {
373-
let isValid = true;
375+
async function validateEffects(effects) {
374376
if (effects) {
375377
for (let i = 0; i < effects.length; i++) {
376378
if (!(common.validateArgs(effects[i].configuration, CheckEffectProperties(effects[i])))) {
377-
isValid = false;
378-
break;
379+
return { valid: false, error: 'Invalid effect configuration' };
380+
}
381+
382+
// SSRF protection: validate HTTPEffect URLs with DNS resolution
383+
if (effects[i].type === "HTTPEffect" && effects[i].configuration && effects[i].configuration.url) {
384+
const urlCheck = await ssrfProtection.isUrlSafe(effects[i].configuration.url);
385+
if (!urlCheck.safe) {
386+
return { valid: false, error: 'Unsafe URL in HTTPEffect: ' + urlCheck.error };
387+
}
379388
}
380389
}
381390
}
382391

383-
return isValid;
392+
return { valid: true, error: null };
384393

385394
}
386395

@@ -636,9 +645,12 @@ plugins.register("/i/hook/test", function(ob) {
636645
}
637646

638647
// Null check for effects
639-
if (hookConfig.effects && !validateEffects(hookConfig.effects)) {
640-
common.returnMessage(params, 400, 'Config invalid');
641-
return; // Add return to exit early
648+
if (hookConfig.effects) {
649+
const effectValidation = await validateEffects(hookConfig.effects);
650+
if (!effectValidation.valid) {
651+
common.returnMessage(params, 400, effectValidation.error || 'Config invalid');
652+
return; // Add return to exit early
653+
}
642654
}
643655

644656

plugins/hooks/api/parts/effects/http.js

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const plugins = require("../../../../pluginManager.js");
22
const request = require("countly-request")(plugins.getConfig("security"));
33
const utils = require("../../utils");
44
const common = require('../../../../../api/utils/common.js');
5+
const ssrfProtection = require('../../ssrf-protection');
56
const log = common.log("hooks:api:api_endpoint_trigger");
67

78
/**
@@ -47,17 +48,39 @@ class HTTPEffect {
4748
const parsedRequestData = utils.parseStringTemplate(requestData, params, method);
4849
log.d("[hook http effect ]", parsedURL, parsedRequestData, method);
4950

50-
// todo: assemble params for request;
51-
// const params = {}
51+
// Revalidate URL after template expansion.
52+
// The URL was validated at save time, but template variables
53+
// (e.g. {{path}}) may have been replaced with malicious values
54+
// that redirect to internal/private addresses.
55+
const urlCheck = await ssrfProtection.isUrlSafe(parsedURL);
56+
if (!urlCheck.safe) {
57+
const ssrfError = new Error('SSRF protection: blocked HTTP effect — ' + urlCheck.error);
58+
logs.push(`Error: ${ssrfError.message}`);
59+
utils.addErrorRecord(rule._id, ssrfError, params, effectStep, _originalInput);
60+
log.e("[hook http effect ] SSRF blocked:", urlCheck.error);
61+
return {...options, logs};
62+
}
63+
5264
const requestHeaders = headers || {};
5365
const methodOption = method && method.toLowerCase() || "get";
5466
switch (methodOption) {
55-
case 'get':
56-
await request.get({
57-
uri: parsedURL + "?" + parsedRequestData,
67+
case 'get': {
68+
// For GET, also validate the full URL with query string
69+
const fullGetUrl = parsedURL + "?" + parsedRequestData;
70+
const getUrlCheck = await ssrfProtection.isUrlSafe(fullGetUrl);
71+
if (!getUrlCheck.safe) {
72+
const ssrfError = new Error('SSRF protection: blocked GET URL — ' + getUrlCheck.error);
73+
logs.push(`Error: ${ssrfError.message}`);
74+
utils.addErrorRecord(rule._id, ssrfError, params, effectStep, _originalInput);
75+
log.e("[hook http effect ] SSRF blocked:", getUrlCheck.error);
76+
return {...options, logs};
77+
}
78+
79+
await request.get(ssrfProtection.getSsrfSafeOptions({
80+
uri: fullGetUrl,
5881
timeout: this._timeout,
59-
headers: requestHeaders
60-
}, function(e, r, body) {
82+
headers: requestHeaders,
83+
}), function(e, r, body) {
6184
log.d("[http get effect]", e, body);
6285
if (e) {
6386
logs.push(`Error: ${e.message}`);
@@ -66,6 +89,7 @@ class HTTPEffect {
6689
}
6790
});
6891
break;
92+
}
6993
case 'post': {
7094
//support post formData
7195
let parsedJSON = {};
@@ -90,13 +114,13 @@ class HTTPEffect {
90114
utils.addErrorRecord(rule._id, e, params, effectStep, _originalInput);
91115
}
92116
if (Object.keys(parsedJSON).length) {
93-
await request({
117+
await request(ssrfProtection.getSsrfSafeOptions({
94118
method: 'POST',
95119
uri: parsedURL,
96120
json: parsedJSON,
97121
timeout: this._timeout,
98-
headers: requestHeaders
99-
},
122+
headers: requestHeaders,
123+
}),
100124
function(e, r, body) {
101125
log.d("[httpeffects]", e, body, rule);
102126
if (e) {

0 commit comments

Comments
 (0)