From 90db746ab3532b946572f70c27ed5aab89a24f80 Mon Sep 17 00:00:00 2001 From: Danu Widatama Date: Wed, 18 Mar 2026 19:15:24 +0700 Subject: [PATCH 1/4] [hooks] Implement ssrf protection --- plugins/hooks/api/api.js | 40 +++-- plugins/hooks/api/parts/effects/http.js | 44 +++-- plugins/hooks/api/ssrf-protection.js | 223 ++++++++++++++++++++++++ plugins/hooks/package-lock.json | 10 ++ plugins/hooks/package.json | 1 + 5 files changed, 294 insertions(+), 24 deletions(-) create mode 100644 plugins/hooks/api/ssrf-protection.js diff --git a/plugins/hooks/api/api.js b/plugins/hooks/api/api.js index 09ad6e5a57f..cf28e9edacb 100644 --- a/plugins/hooks/api/api.js +++ b/plugins/hooks/api/api.js @@ -9,6 +9,7 @@ const log = common.log('hooks:api'); const _ = require('lodash'); const utils = require('./utils'); const rights = require('../../../api/utils/rights'); +const ssrfProtection = require('./ssrf-protection'); const FEATURE_NAME = 'hooks'; @@ -232,7 +233,7 @@ const CheckEffectProperties = function(effect) { //todo: add more validation for effect types if (effect) { if (effect.type === "HTTPEffect") { - 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*)?$' }; + rules.url = { 'required': true, 'type': 'URL' }; rules.headers = { 'required': false, 'type': 'Object' }; } } @@ -273,7 +274,7 @@ plugins.register("/permissions/features", function(ob) { plugins.register("/i/hook/save", function(ob) { let paramsInstance = ob.params; - validateCreate(ob.params, FEATURE_NAME, function(params) { + validateCreate(ob.params, FEATURE_NAME, async function(params) { let hookConfig = params.qstring.hook_config; if (!hookConfig) { common.returnMessage(params, 400, 'Invalid hookConfig'); @@ -290,9 +291,12 @@ plugins.register("/i/hook/save", function(ob) { return true; } - if (hookConfig.effects && !validateEffects(hookConfig.effects)) { - common.returnMessage(params, 400, 'Invalid configuration for effects'); - return true; + if (hookConfig.effects) { + const effectValidation = await validateEffects(hookConfig.effects); + if (!effectValidation.valid) { + common.returnMessage(params, 400, effectValidation.error || 'Invalid configuration for effects'); + return true; + } } if (hookConfig._id) { @@ -367,20 +371,25 @@ plugins.register("/i/hook/save", function(ob) { /*** * @param {array} effects - array of effects - * @returns {boolean} isValid - true if all effects are valid */ -function validateEffects(effects) { - let isValid = true; +async function validateEffects(effects) { if (effects) { for (let i = 0; i < effects.length; i++) { if (!(common.validateArgs(effects[i].configuration, CheckEffectProperties(effects[i])))) { - isValid = false; - break; + return { valid: false, error: 'Invalid effect configuration' }; + } + + // SSRF protection: validate HTTPEffect URLs with DNS resolution + if (effects[i].type === "HTTPEffect" && effects[i].configuration && effects[i].configuration.url) { + const urlCheck = await ssrfProtection.isUrlSafe(effects[i].configuration.url); + if (!urlCheck.safe) { + return { valid: false, error: 'Unsafe URL in HTTPEffect: ' + urlCheck.error }; + } } } } - return isValid; + return { valid: true, error: null }; } @@ -636,9 +645,12 @@ plugins.register("/i/hook/test", function(ob) { } // Null check for effects - if (hookConfig.effects && !validateEffects(hookConfig.effects)) { - common.returnMessage(params, 400, 'Config invalid'); - return; // Add return to exit early + if (hookConfig.effects) { + const effectValidation = await validateEffects(hookConfig.effects); + if (!effectValidation.valid) { + common.returnMessage(params, 400, effectValidation.error || 'Config invalid'); + return; // Add return to exit early + } } diff --git a/plugins/hooks/api/parts/effects/http.js b/plugins/hooks/api/parts/effects/http.js index f84da7f2ac5..f37089cb29a 100644 --- a/plugins/hooks/api/parts/effects/http.js +++ b/plugins/hooks/api/parts/effects/http.js @@ -2,6 +2,7 @@ const plugins = require("../../../../pluginManager.js"); const request = require("countly-request")(plugins.getConfig("security")); const utils = require("../../utils"); const common = require('../../../../../api/utils/common.js'); +const ssrfProtection = require('../../ssrf-protection'); const log = common.log("hooks:api:api_endpoint_trigger"); /** @@ -47,17 +48,39 @@ class HTTPEffect { const parsedRequestData = utils.parseStringTemplate(requestData, params, method); log.d("[hook http effect ]", parsedURL, parsedRequestData, method); - // todo: assemble params for request; - // const params = {} + // Revalidate URL after template expansion. + // The URL was validated at save time, but template variables + // (e.g. {{path}}) may have been replaced with malicious values + // that redirect to internal/private addresses. + const urlCheck = await ssrfProtection.isUrlSafe(parsedURL); + if (!urlCheck.safe) { + const ssrfError = new Error('SSRF protection: blocked HTTP effect — ' + urlCheck.error); + logs.push(`Error: ${ssrfError.message}`); + utils.addErrorRecord(rule._id, ssrfError, params, effectStep, _originalInput); + log.e("[hook http effect ] SSRF blocked:", urlCheck.error); + return {...options, logs}; + } + const requestHeaders = headers || {}; const methodOption = method && method.toLowerCase() || "get"; switch (methodOption) { - case 'get': - await request.get({ - uri: parsedURL + "?" + parsedRequestData, + case 'get': { + // For GET, also validate the full URL with query string + const fullGetUrl = parsedURL + "?" + parsedRequestData; + const getUrlCheck = await ssrfProtection.isUrlSafe(fullGetUrl); + if (!getUrlCheck.safe) { + const ssrfError = new Error('SSRF protection: blocked GET URL — ' + getUrlCheck.error); + logs.push(`Error: ${ssrfError.message}`); + utils.addErrorRecord(rule._id, ssrfError, params, effectStep, _originalInput); + log.e("[hook http effect ] SSRF blocked:", getUrlCheck.error); + return {...options, logs}; + } + + await request.get(ssrfProtection.getSsrfSafeOptions({ + uri: fullGetUrl, timeout: this._timeout, - headers: requestHeaders - }, function(e, r, body) { + headers: requestHeaders, + }), function(e, r, body) { log.d("[http get effect]", e, body); if (e) { logs.push(`Error: ${e.message}`); @@ -66,6 +89,7 @@ class HTTPEffect { } }); break; + } case 'post': { //support post formData let parsedJSON = {}; @@ -90,13 +114,13 @@ class HTTPEffect { utils.addErrorRecord(rule._id, e, params, effectStep, _originalInput); } if (Object.keys(parsedJSON).length) { - await request({ + await request(ssrfProtection.getSsrfSafeOptions({ method: 'POST', uri: parsedURL, json: parsedJSON, timeout: this._timeout, - headers: requestHeaders - }, + headers: requestHeaders, + }), function(e, r, body) { log.d("[httpeffects]", e, body, rule); if (e) { diff --git a/plugins/hooks/api/ssrf-protection.js b/plugins/hooks/api/ssrf-protection.js new file mode 100644 index 00000000000..88be7553349 --- /dev/null +++ b/plugins/hooks/api/ssrf-protection.js @@ -0,0 +1,223 @@ +/** + * @module plugins/hooks/api/ssrf-protection + * @description SSRF (Server-Side Request Forgery) protection utilities + * for the Hooks plugin's HTTPEffect. + * + * Provides IP blocklist checking, DNS-level validation, and URL safety + * verification to prevent requests to internal/private network addresses, + * cloud metadata endpoints, and other dangerous targets. + * + * Uses ipaddr.js for robust IP address parsing and range classification, + * covering all RFC-defined private, reserved, loopback, link-local, + * multicast, carrier-grade NAT, and documentation ranges for both + * IPv4 and IPv6 (including IPv4-mapped IPv6 and NAT64). + * + * - URL parse time validation with DNS (isUrlSafe) + * - Protocol restriction (only http/https) + * - Redirects disabled (followRedirect: false via getSsrfSafeOptions) + * - Revalidation after template expansion (when doing request in http effect) + */ + +'use strict'; + +const dns = require('dns'); +const net = require('net'); +const { URL } = require('url'); +const ipaddr = require('ipaddr.js'); + +/** + * Allowed URL protocols for outbound requests. + */ +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']); + +/** + * Hostnames known to serve cloud metadata / internal services. + * Checked as exact match (case-insensitive). + */ +const BLOCKED_HOSTNAMES = new Set([ + 'metadata.google.internal', + 'metadata.goog', + 'metadata.google.com', + 'kubernetes.default.svc', + 'kubernetes.default', + 'kubernetes', +]); + +/** + * Check whether an IP address (v4 or v6) is private/reserved/internal. + * + * Uses ipaddr.js range() classification. Only 'unicast' addresses are + * considered safe. All other ranges are blocked: + * - unspecified (0.0.0.0/8, ::) + * - loopback (127.0.0.0/8, ::1) + * - private (10/8, 172.16/12, 192.168/16) + * - linkLocal (169.254/16, fe80::/10) + * - multicast (224/4, ff00::/8) + * - broadcast (255.255.255.255) + * - reserved (192.0.0/24, 192.0.2/24, 192.88.99/24, 198.18/15, + * 198.51.100/24, 203.0.113/24, 240/4, 2001:db8::/32) + * - carrierGradeNat (100.64/10) + * - uniqueLocal (fc00::/7) + * - ipv4Mapped (::ffff:0:0/96 — unwrapped and re-checked as IPv4) + * - rfc6052 (64:ff9b::/96 NAT64) + * - discard (100::/64) + * + * @param {string} ip - IP address string + * @returns {boolean} true if the IP should be blocked + */ +function isBlockedIP(ip) { + let parsed; + try { + parsed = ipaddr.parse(ip); + } + catch (e) { + // Unparseable IP — block to be safe + return true; + } + + const range = parsed.range(); + + // IPv4-mapped IPv6 (::ffff:x.x.x.x): unwrap and check the inner IPv4 + if (range === 'ipv4Mapped' && parsed.isIPv4MappedAddress()) { + return parsed.toIPv4Address().range() !== 'unicast'; + } + + return range !== 'unicast'; +} + +/** + * Check whether a hostname is a known dangerous internal service. + * + * @param {string} hostname - The hostname to check (will be lowercased) + * @returns {boolean} true if the hostname should be blocked + */ +function isBlockedHostname(hostname) { + const lower = hostname.toLowerCase(); + + // Exact match against known dangerous hosts + if (BLOCKED_HOSTNAMES.has(lower)) { + return true; + } + + // Block "localhost" and variants + if (lower === 'localhost' || lower.endsWith('.localhost')) { + return true; + } + + // Block .internal TLD (used by GCP metadata and internal services) + if (lower.endsWith('.internal')) { + return true; + } + + return false; +} + +/** + * Strip IPv6 brackets from a hostname if present. + * new URL('http://[::1]/').hostname returns '[::1]' with brackets, + * but net.isIP() and our IP checkers expect '::1' without brackets. + * + * @param {string} hostname - hostname possibly wrapped in brackets + * @returns {string} hostname with brackets stripped if it was a bracketed IPv6 + */ +function stripIPv6Brackets(hostname) { + if (hostname.startsWith('[') && hostname.endsWith(']')) { + return hostname.slice(1, -1); + } + return hostname; +} + +/** + * Validate a URL string for SSRF safety. + * + * @param {string} urlString - The URL to validate + */ +async function isUrlSafe(urlString) { + // Must be a non-empty string + if (!urlString || typeof urlString !== 'string') { + return { safe: false, error: 'URL must be a non-empty string' }; + } + + // Parse the URL + let parsed; + try { + parsed = new URL(urlString); + } + catch (e) { + return { safe: false, error: 'Invalid URL: ' + e.message }; + } + + // Protocol restriction + if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) { + return { safe: false, error: `Protocol "${parsed.protocol}" is not allowed. Only http and https are permitted.` }; + } + + // Block credentials in URL (user:pass@host) + if (parsed.username || parsed.password) { + return { safe: false, error: 'URLs with embedded credentials are not allowed' }; + } + + const hostname = parsed.hostname; + + // Check against blocked hostnames + if (isBlockedHostname(hostname)) { + return { safe: false, error: `Hostname "${hostname}" is blocked` }; + } + + // Strip IPv6 brackets for IP checks (URL parser returns [::1], net.isIP expects ::1) + const bareHostname = stripIPv6Brackets(hostname); + + // If hostname is an IP literal, check it directly + if (net.isIP(bareHostname)) { + if (isBlockedIP(bareHostname)) { + return { safe: false, error: `IP address "${bareHostname}" is in a private/reserved range` }; + } + // IP is public — OK at parse time + return { safe: true, error: null }; + } + + // Hostname is a domain name — resolve it to check the IP + try { + const address = await new Promise((resolve, reject) => { + dns.lookup(hostname, (err, addr) => { + if (err) { + reject(err); + } + else { + resolve(addr); + } + }); + }); + + if (isBlockedIP(address)) { + return { safe: false, error: `Hostname "${hostname}" resolves to private/reserved IP "${address}"` }; + } + } + catch (e) { + return { safe: false, error: `DNS resolution failed for "${hostname}": ${e.message}` }; + } + + return { safe: true, error: null }; +} + +/** + * Build got-compatible request options with SSRF protection baked in. + * + * Disables redirects to prevent redirect-based SSRF bypasses. + * + * @param {object} requestOptions - base request options (uri, timeout, headers, etc.) + * @returns {object} the same options object with SSRF settings injected + */ +function getSsrfSafeOptions(requestOptions) { + const options = Object.assign({}, requestOptions); + + // Disable redirects entirely — prevents redirect-based SSRF bypasses + options.followRedirect = false; + + return options; +} + +module.exports = { + isUrlSafe, + getSsrfSafeOptions, +}; diff --git a/plugins/hooks/package-lock.json b/plugins/hooks/package-lock.json index 4fd5e6c7f1b..86369330e25 100644 --- a/plugins/hooks/package-lock.json +++ b/plugins/hooks/package-lock.json @@ -8,6 +8,7 @@ "name": "countly-hooks", "version": "1.0.0", "dependencies": { + "ipaddr.js": "^2.3.0", "v8-sandbox": "3.2.12" } }, @@ -680,6 +681,15 @@ "node": ">= 12" } }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", diff --git a/plugins/hooks/package.json b/plugins/hooks/package.json index bf60682a0b7..054459cdb7c 100644 --- a/plugins/hooks/package.json +++ b/plugins/hooks/package.json @@ -20,6 +20,7 @@ "template" ], "dependencies": { + "ipaddr.js": "^2.3.0", "v8-sandbox": "3.2.12" }, "private": true From edab4601b089fe7eb0e7f57024f50dc2e0b84572 Mon Sep 17 00:00:00 2001 From: Danu Widatama Date: Wed, 18 Mar 2026 20:27:06 +0700 Subject: [PATCH 2/4] [hooks] Add SSRF tests and split test files into tests/ directory --- plugins/hooks/{tests.js => tests/crud.js} | 6 +- plugins/hooks/tests/index.js | 2 + plugins/hooks/tests/ssrf.js | 112 ++++++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) rename plugins/hooks/{tests.js => tests/crud.js} (98%) create mode 100644 plugins/hooks/tests/index.js create mode 100644 plugins/hooks/tests/ssrf.js diff --git a/plugins/hooks/tests.js b/plugins/hooks/tests/crud.js similarity index 98% rename from plugins/hooks/tests.js rename to plugins/hooks/tests/crud.js index b883fb96041..c89c4379174 100644 --- a/plugins/hooks/tests.js +++ b/plugins/hooks/tests/crud.js @@ -2,9 +2,9 @@ var request = require('supertest'); var should = require('should'); var crypto = require('crypto'); var moment = require('moment-timezone'); -var testUtils = require("../../test/testUtils"); -var pluginManager = require("../../plugins/pluginManager.js"); -var Promise = require("bluebird"); +var testUtils = require('../../../test/testUtils'); +var pluginManager = require('../../../plugins/pluginManager.js'); +var Promise = require('bluebird'); request = request(testUtils.url); diff --git a/plugins/hooks/tests/index.js b/plugins/hooks/tests/index.js new file mode 100644 index 00000000000..802b6c38533 --- /dev/null +++ b/plugins/hooks/tests/index.js @@ -0,0 +1,2 @@ +require('./crud.js'); +require('./ssrf.js'); diff --git a/plugins/hooks/tests/ssrf.js b/plugins/hooks/tests/ssrf.js new file mode 100644 index 00000000000..9122916efd0 --- /dev/null +++ b/plugins/hooks/tests/ssrf.js @@ -0,0 +1,112 @@ +var request = require('supertest'); +var should = require('should'); +var testUtils = require('../../../test/testUtils'); +request = request(testUtils.url); + +/** + * Build a hook config with a single HTTPEffect targeting the given URL. + * @param {string} url - target URL for the HTTP effect + * @param {string} appId - application ID + * @returns {object} hook config + */ +function buildHookConfig(url, appId) { + return { + name: 'ssrf-test', + description: 'SSRF validation test', + apps: [appId], + trigger: { + type: 'APIEndPointTrigger', + configuration: { + path: `ssrf-test-${Date.now()}`, + method: 'get', + }, + }, + effects: [{ + type: 'HTTPEffect', + configuration: { + url: url, + method: 'get', + requestData: '', + }, + }], + enabled: true, + }; +} + +function getRequestURL(path) { + var API_KEY_ADMIN = testUtils.get('API_KEY_ADMIN'); + var APP_ID = testUtils.get('APP_ID'); + return `${path}?api_key=${API_KEY_ADMIN}&app_id=${APP_ID}`; +} + +const createdHookIds = []; + +describe('SSRF Protection', () => { + after('Clean up created hooks', async() => { + if (createdHookIds.length === 0) { + return; + } + + for (let hookID of createdHookIds) { + await request.post(getRequestURL('/i/hook/delete')) + .send({ hookID }) + .expect(200); + } + }); + + describe('Blocked URLs', () => { + var blockedCases = [ + {url: 'http://localhost/test', label: 'localhost'}, + {url: 'http://127.0.0.1/test', label: 'IPv4 loopback (127.0.0.1)'}, + {url: 'http://[::1]/test', label: 'IPv6 loopback (::1)'}, + {url: 'http://169.254.169.254/latest/meta-data/', label: 'AWS IMDS (169.254.169.254)'}, + {url: 'http://metadata.google.internal/', label: 'GCP metadata'}, + {url: 'http://10.0.0.1/', label: 'Private (10.x)'}, + {url: 'http://172.16.0.1/', label: 'Private (172.16.x)'}, + {url: 'http://192.168.1.1/', label: 'Private (192.168.x)'}, + {url: 'http://127.0.0.2/', label: 'Alternative loopback (127.0.0.2)'}, + {url: 'http://0x7f.0.0.1/', label: 'Hex-encoded loopback'}, + {url: 'http://0177.0.0.1/', label: 'Octal-encoded loopback'}, + {url: 'http://0.0.0.0/', label: 'All interfaces (0.0.0.0)'}, + {url: 'http://localtest.me/', label: 'localtest.me (resolves to 127.0.0.1)'}, + ]; + + for (let tc of blockedCases) { + it(`should block ${tc.label} (${tc.url})`, async() => { + var APP_ID = testUtils.get('APP_ID'); + var hookConfig = buildHookConfig(tc.url, APP_ID); + + const res = await request.post(getRequestURL('/i/hook/save')) + .send({hook_config: JSON.stringify(hookConfig)}) + .expect(400); + + should(res.body).have.property('result'); + should(res.body.result).be.a.String(); + should(res.body.result).match(/Unsafe URL|blocked|private|reserved|loopback/i); + }); + } + }); + + describe('Allowed URLs', () => { + var allowedCases = [ + {url: 'https://example.com/', label: 'example.com (external domain)'}, + {url: 'https://google.com/', label: 'google.com (external domain)'}, + {url: 'http://93.184.216.34/', label: 'Public IPv4 (93.184.216.34)'}, + ]; + + for (let tc of allowedCases) { + it('should allow ' + tc.label + ' (' + tc.url + ')', async() => { + var APP_ID = testUtils.get('APP_ID'); + var hookConfig = buildHookConfig(tc.url, APP_ID); + + const res = await request.post(getRequestURL('/i/hook/save')) + .send({hook_config: JSON.stringify(hookConfig)}) + .expect(200); + + should(res.body).be.a.String(); + should(res.body).match(/^[a-f0-9]{24}$/); + createdHookIds.push(res.body); + }); + } + }); +}); From 7dd2c299c80677e96c27648cd682a4eb74d7b1c4 Mon Sep 17 00:00:00 2001 From: Danu Widatama Date: Wed, 18 Mar 2026 23:33:09 +0700 Subject: [PATCH 3/4] [hooks] Disable auto close on drawer submission --- .../public/javascripts/countly.models.js | 47 +++++++++++++------ .../public/javascripts/countly.views.js | 11 +++-- .../public/localization/hooks.properties | 1 + .../frontend/public/templates/vue-drawer.html | 13 ++--- 4 files changed, 48 insertions(+), 24 deletions(-) diff --git a/plugins/hooks/frontend/public/javascripts/countly.models.js b/plugins/hooks/frontend/public/javascripts/countly.models.js index ae97d849408..bc50ec1f3f7 100644 --- a/plugins/hooks/frontend/public/javascripts/countly.models.js +++ b/plugins/hooks/frontend/public/javascripts/countly.models.js @@ -274,20 +274,29 @@ saveHook: function(context, record) { delete record._canUpdate; delete record._canDelete; - return CV.$.ajax({ - type: "POST", - url: countlyCommon.API_PARTS.data.w + "/hook/save?" + "app_id=" + record.apps[0], - data: { - "hook_config": JSON.stringify(record) - }, - dataType: "json", - success: function() { - context.dispatch("countlyHooks/table/fetchAll", null, {root: true}); - context.dispatch("countlyHooks/initializeDetail", record._id, {root: true}); - }, - error: function() { - CountlyHelpers.notify({type: "error", message: jQuery.i18n.map["hooks.trigger-save-failed"]}); - } + return new Promise(function(resolve, reject) { + CV.$.ajax({ + type: 'POST', + url: countlyCommon.API_PARTS.data.w + '/hook/save?' + 'app_id=' + record.apps[0], + data: { + 'hook_config': JSON.stringify(record) + }, + dataType: 'json', + success: function() { + context.dispatch('countlyHooks/table/fetchAll', null, {root: true}); + context.dispatch('countlyHooks/initializeDetail', record._id, {root: true}); + resolve(); + }, + error: function(err) { + var msg = jQuery.i18n.map['hooks.trigger-save-failed']; + if (err.responseJSON && err.responseJSON.result) { + msg = err.responseJSON.result; + } + + CountlyHelpers.notify({type: 'error', message: msg}); + reject(err); + }, + }); }); }, deleteHook: function(context, id) { @@ -326,7 +335,15 @@ }); context.commit("setTestResult", res.result); } - } + }, + error: function(err) { + var msg = jQuery.i18n.map['hooks.test-hook-failed']; + if (err.responseJSON && err.responseJSON.result) { + msg = err.responseJSON.result; + } + + CountlyHelpers.notify({type: "error", message: msg}); + }, }); }, resetTestResult: function(context) { diff --git a/plugins/hooks/frontend/public/javascripts/countly.views.js b/plugins/hooks/frontend/public/javascripts/countly.views.js index 6ea61538b70..14af71fe402 100644 --- a/plugins/hooks/frontend/public/javascripts/countly.views.js +++ b/plugins/hooks/frontend/public/javascripts/countly.views.js @@ -800,8 +800,14 @@ } }, methods: { - onSubmit: function(doc) { - this.$store.dispatch("countlyHooks/saveHook", doc); + onSubmit: function(doc, callback) { + this.$store.dispatch("countlyHooks/saveHook", doc) + .then(function() { + callback(); + }) + .catch(function(err) { + callback(err); + }); }, onClose: function($event) { this.$emit("close", $event); @@ -822,7 +828,6 @@ removeEffect: function(index) { this.$refs.drawerData.editedObject.effects.splice(index, 1); }, - testHook: function() { var hookData = this.$refs.drawerData.editedObject; this.$data.newTest = true; diff --git a/plugins/hooks/frontend/public/localization/hooks.properties b/plugins/hooks/frontend/public/localization/hooks.properties index 36b41cf4e76..24f22e242d4 100644 --- a/plugins/hooks/frontend/public/localization/hooks.properties +++ b/plugins/hooks/frontend/public/localization/hooks.properties @@ -114,6 +114,7 @@ hooks.effects-intro = Select the actions the hook will perform upon being trigge hooks.test-hook = HOOK TESTING hooks.test-hook-intro = Test your hooks to verify payloads or check if your hook setup is working properly before taking it live. hooks.test-hook-button = Test your hook setup +hooks.test-hook-failed = Hook test failed. hooks.remove-action = Remove action hooks.send-email = Email addresses to send the data to hooks.http-intro = Query string (for GET) or request body (for POST) diff --git a/plugins/hooks/frontend/public/templates/vue-drawer.html b/plugins/hooks/frontend/public/templates/vue-drawer.html index 3f841def44e..e6ad9a99b4f 100644 --- a/plugins/hooks/frontend/public/templates/vue-drawer.html +++ b/plugins/hooks/frontend/public/templates/vue-drawer.html @@ -1,13 +1,14 @@ + v-bind="$props.controls">