diff --git a/src/appmixer/cloudflare/CloudflareAPI.js b/src/appmixer/cloudflare/CloudflareAPI.js new file mode 100644 index 000000000..11b35cadb --- /dev/null +++ b/src/appmixer/cloudflare/CloudflareAPI.js @@ -0,0 +1,54 @@ +module.exports = class CloudflareAPI { + + constructor({ email, apiKey, zoneId, token }) { + this.email = email; + this.zoneId = zoneId; + this.email = email; + this.apiKey = apiKey; + this.token = token; + } + + getHeaders() { + if (this.token) { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.token}` + }; + } + + return { + 'Content-Type': 'application/json', + 'X-Auth-Email': this.email, + 'X-Auth-Key': this.apiKey + }; + } + + async verifyGlobalApiKey(context) { + + const headers = this.getHeaders(); + + return context.httpRequest({ + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts', + headers + }); + } + + async callEndpoint(context, { + action, + method = 'GET', + data, + params + }) { + + const headers = this.getHeaders(); + + return context.httpRequest({ + method, + url: `https://api.cloudflare.com/client/v4${action}`, + headers, + data, + params + }); + } +}; diff --git a/src/appmixer/cloudflare/CloudflareZonesUrlBuilder.js b/src/appmixer/cloudflare/CloudflareZonesUrlBuilder.js deleted file mode 100644 index f4348b2b6..000000000 --- a/src/appmixer/cloudflare/CloudflareZonesUrlBuilder.js +++ /dev/null @@ -1,32 +0,0 @@ -module.exports = class CloudflareZonesUrlBuilder { - zonesBaseUrl = 'https://api.cloudflare.com/client/v4/zones'; - - constructor(zoneId) { - this.zoneId = zoneId; - this.url = `${this.zonesBaseUrl}/${this.zoneId}`; - } - - addRulesets() { - this.url += '/rulesets'; - return this; - } - - addRulesetId(rulesetId) { - this.url += `/${rulesetId}`; - return this; - } - - addRules() { - this.url += '/rules'; - return this; - } - - addRuleId(ruleId) { - this.url += `/${ruleId}`; - return this; - } - - getUrl() { - return this.url; - } -}; diff --git a/src/appmixer/cloudflare/ZoneCloudflareClient.js b/src/appmixer/cloudflare/ZoneCloudflareClient.js deleted file mode 100644 index 29b1ae6bf..000000000 --- a/src/appmixer/cloudflare/ZoneCloudflareClient.js +++ /dev/null @@ -1,221 +0,0 @@ -const { Address4, Address6 } = require('ip-address'); - -const CloudflareZonesUrlBuilder = require('./CloudflareZonesUrlBuilder'); - -module.exports = class ZoneCloudflareClient { - ruleDescription = 'Salt detected high severity attacker'; - ruleRefPrefix = 'SALT'; - - constructor({ email, apiKey, zoneId, token }) { - this.email = email; - this.apiKey = apiKey; - this.zoneId = zoneId; - this.email = email; - this.apiKey = apiKey; - this.token = token; - } - - getHeaders() { - if (this.token) { - return { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.token}` - }; - } - - return { - 'Content-Type': 'application/json', - 'X-Auth-Email': this.email, - 'X-Auth-Key': this.apiKey - }; - } - - getRuleDescription(attackerId) { - return `${this.ruleDescription} ${attackerId}`; - } - - getRuleRef(attackerId) { - return `${this.ruleRefPrefix}-${attackerId}`; - } - - removeInterfaceIdentifierAndAddCidr(ip) { - const networkPrefix = ip - .split(':') - .slice(0, 4) - .join(':'); - return `${networkPrefix}::/64`; - } - - getBlockExpression(ips) { - const ipv4 = ips.filter(ip => Address4.isValid(ip)); - const ipv6 = ips.filter(ip => Address6.isValid(ip)); - const formattedIpv6 = ipv6.length > 0 - ? ipv6.map(ip => this.removeInterfaceIdentifierAndAddCidr(ip)) - : ipv6; - const allIps = [...ipv4, ...formattedIpv6].sort(); - - if (ipv4.length === 1 && formattedIpv6.length === 0) { - return `ip.src eq ${ipv4[0]}`; // To be backward compatible with the current integration behavior - } - return `ip.src in {${allIps.join(' ')}}`; - } - - getBlockRule(attackerId, ips) { - return { - action: 'block', - description: this.getRuleDescription(attackerId), - enabled: true, - expression: this.getBlockExpression(ips), - ref: this.getRuleRef(attackerId) - }; - } - - /** - * https://developers.cloudflare.com/api/operations/getZoneRuleset - */ - listZoneRulesets(context) { - - return context.httpRequest({ - method: 'GET', - url: `https://api.cloudflare.com/client/v4/zones/${this.zoneId}/rulesets`, - headers: this.getHeaders() - }).then(resp => resp.data.result); - } - - async verify(context) { - - const headers = this.getHeaders(); - - return context.httpRequest({ - method: 'GET', - url: 'https://api.cloudflare.com/client/v4/user/tokens/verify', - headers - }); - } - - async verifyGlobalApiKey(context) { - - const headers = this.getHeaders(); - - return context.httpRequest({ - method: 'GET', - url: 'https://api.cloudflare.com/client/v4/accounts', - headers - }); - } - - async callEndpoint(context, { - action, - method = 'GET', - data, - params - }) { - - const headers = this.getHeaders(); - - return context.httpRequest({ - method, - url: `https://api.cloudflare.com/client/v4${action}`, - headers, - data, - params - }); - } - - async getRules(context, rulesetId) { - - const response = await context.httpRequest({ - method: 'GET', - url: `https://api.cloudflare.com/client/v4/zones/${this.zoneId}/rulesets/${rulesetId}`, - headers: this.getHeaders() - }); - - return response.data; - } - - async createRulesetAndBlockRule(context, attackerId, ips) { - - const { data } = await context.httpRequest({ - method: 'POST', - url: `https://api.cloudflare.com/client/v4/zones/${this.zoneId}/rulesets`, - headers: this.getHeaders(), - data: { - kind: 'zone', - phase: 'http_request_firewall_custom', - name: 'Http Request Firewall Custom Ruleset', - description: 'Created by Salt Security\'s Cloudflare Integration. Firewall rules of identified attackers will be added to this ruleset.', - rules: [this.getBlockRule(attackerId, ips)] - } - }); - - return data; - } - - createBlockRule(context, { rulesetId, attackerId, ips }) { - const url = new CloudflareZonesUrlBuilder(this.zoneId) - .addRulesets() - .addRulesetId(rulesetId) - .addRules() - .getUrl(); - const headers = this.getHeaders(); - const body = this.getBlockRule(attackerId, ips); - return context.httpRequest.post(url, body, { headers }).then(resp => resp.data); - } - - updateBlockRule(context, rulesetId, ruleId, attackerId, ips) { - const url = new CloudflareZonesUrlBuilder(this.zoneId) - .addRulesets() - .addRulesetId(rulesetId) - .addRules() - .addRuleId(ruleId) - .getUrl(); - const headers = this.getHeaders(); - const body = this.getBlockRule(attackerId, ips); - return context.httpRequest.patch(url, body, { headers }).then(resp => resp.data); - } - - async findIdsForIPs({ context, client, ips = [], account, list }) { - - const result = []; - for (let ipItem of ips) { - - try { - const { data } = await client.callEndpoint(context, { - method: 'GET', - action: `/accounts/${account}/rules/lists/${list}/items`, - params: { - per_page: 1, - search: ipItem.ip - } - }); - if (data?.result[0] && data?.result.length === 1) { - result.push({ ...data.result[0] }); - } - - } catch (err) { - context.log({ stage: `Invalid IP, IP ${ipItem} hasn't been found in the list ${list}` }); - } - } - - return result; - }; - - isCloudflareGetRulesetResponse(data) { - return ( - data && - typeof data === 'object' && - Array.isArray(data.errors) && - Array.isArray(data.messages) && - data.result && - typeof data.result === 'object' && - typeof data.result.description === 'string' && - typeof data.result.id === 'string' && - typeof data.result.kind === 'string' && - typeof data.result.last_updated === 'string' && - typeof data.result.name === 'string' && - typeof data.result.phase === 'string' && - typeof data.result.version === 'string' && - Array.isArray(data.result.rules) - ); - } -}; diff --git a/src/appmixer/cloudflare/auth.js b/src/appmixer/cloudflare/auth.js index 4a32b345d..f1aa1a03a 100644 --- a/src/appmixer/cloudflare/auth.js +++ b/src/appmixer/cloudflare/auth.js @@ -1,6 +1,6 @@ 'use strict'; -const ZoneCloudflareClient = require('./ZoneCloudflareClient'); +const CloudflareAPI = require('./CloudflareAPI'); module.exports = { @@ -11,11 +11,6 @@ module.exports = { accountNameFromProfileInfo: 'account', auth: { - apiToken: { - type: 'text', - name: 'API Token', - tooltip: 'Manage access and permissions for your accounts, sites, and products".' - }, email: { type: 'text', name: 'Email', @@ -30,23 +25,18 @@ module.exports = { requestProfileInfo: async function(context) { - const { email, apiToken } = context; + const { email } = context; if (email) { return { account: email }; } - - const threshold = 10; - if (apiToken.length > threshold) { - return { account: apiToken.slice(0, 5) + ' ... ' + apiToken.slice(-3) }; - } }, validate: async function(context) { - const client = new ZoneCloudflareClient({ token: context.apiToken }); - - const { data } = await client.verify(context); + const { email, apiKey } = context; + const client = new CloudflareAPI({ email, apiKey }); + const { data } = await client.verifyGlobalApiKey(context); return data.success || false; } } diff --git a/src/appmixer/cloudflare/lists/bundle.json b/src/appmixer/cloudflare/bundle.json similarity index 53% rename from src/appmixer/cloudflare/lists/bundle.json rename to src/appmixer/cloudflare/bundle.json index 5c272ce3b..87e04c876 100644 --- a/src/appmixer/cloudflare/lists/bundle.json +++ b/src/appmixer/cloudflare/bundle.json @@ -1,5 +1,5 @@ { - "name": "appmixer.cloudflare.lists", + "name": "appmixer.cloudflare", "version": "1.0.0", "changelog": [] } diff --git a/src/appmixer/cloudflare/jobs.js b/src/appmixer/cloudflare/jobs.js index 54caa8839..a1f4cb915 100644 --- a/src/appmixer/cloudflare/jobs.js +++ b/src/appmixer/cloudflare/jobs.js @@ -1,4 +1,4 @@ -const ZoneCloudflareClient = require('./ZoneCloudflareClient'); +const listsJobs = require('./jobs.lists'); module.exports = async (context) => { @@ -6,22 +6,22 @@ module.exports = async (context) => { const config = require('./config')(context); - await context.scheduleJob('cloud-flare-lists-ips-delete-job', config.ipDeleteJob.schedule, async () => { + await context.scheduleJob('cloudflare-lists-ips-delete-job', config.ipDeleteJob.schedule, async () => { try { - const lock = await context.job.lock('cloud-flare-lists-rule-block-ips-delete-job', { ttl: config.ipDeleteJob.lockTTL }); - await context.log('trace', '[CloudFlare] rule delete job started.'); + const lock = await context.job.lock('cloudflare-lists-rule-block-ips-delete-job', { ttl: config.ipDeleteJob.lockTTL }); + await context.log('trace', '[Cloudflare Lists] rule delete job started.'); try { - await deleteExpireIps(context); + await listsJobs.deleteExpiredIpsFromList(context); } finally { lock.unlock(); - await context.log('trace', '[CloudFlare] rule delete job finished. Lock unlocked.'); + await context.log('trace', '[Cloudflare Lists] rule delete job finished. Lock unlocked.'); } } catch (err) { if (err.message !== 'locked') { context.log('error', { - stage: '[CloudFlare] Error checking rules to delete', + step: '[Cloudflare Lists] Error checking rules to delete', error: err, errorRaw: context.utils.Error.stringify(err) }); @@ -29,77 +29,13 @@ module.exports = async (context) => { } }); - const deleteExpireIps = async function(context) { - - const expired = await getExpiredItems(context); - - const groups = Object.values(expired); - - const promises = groups.map(chunk => { - - const { email, apiKey, account, list } = chunk.auth; - - context.log('info', { stage: '[CloudFlare] removing ', ips: chunk.ips, list, account }); - - const client = new ZoneCloudflareClient({ email, apiKey }); - // https://developers.cloudflare.com/api/operations/lists-delete-list-items - return client.callEndpoint(context, { - method: 'DELETE', - action: `/accounts/${account}/rules/lists/${list}/items`, - data: { items: chunk.ips } - }); - }); - - const itemsToDelete = { ids: [], lists: [] }; - - (await Promise.allSettled(promises)).forEach(async (result, i) => { - if (result.status === 'fulfilled') { - itemsToDelete.ids = itemsToDelete.ids.concat(groups[i].ips); - itemsToDelete.lists.push(groups[i]?.auth?.list); - } else { - const operations = groups[i].ips.map(item => ({ - updateOne: { - filter: { id: item.id }, update: { $set: { mtime: new Date } } - } - })); - await (context.db.collection(IPListModel.collection)).bulkWrite(operations); - } - }); - - if (itemsToDelete.ids.length) { - const deleted = await context.db.collection(IPListModel.collection) - .deleteMany({ id: { $in: itemsToDelete.ids.map(item => item.id) } }); - await context.log('info', { - stage: `[CloudFlare] Deleted total ${deleted.deletedCount} ips.`, - lists: itemsToDelete.lists, - itemIds: itemsToDelete.ids - }); - } - }; - - const getExpiredItems = async function(context) { - - const expired = await context.db.collection(IPListModel.collection) - .find({ removeAfter: { $lt: Date.now() } }) - .toArray(); - - return expired.reduce((res, item) => { - const key = item.auth.list; // listId - res[key] = res[key] || { ips: [] }; - res[key].auth = item.auth; - res[key].ips.push({ id: item.id }); - - return res; - }, {}); - }; - // Self-healing job to remove rules that have created>mtime. These rules are stuck in the system and should be removed. - await context.scheduleJob('cloud-flare-lists-ips-cleanup-job', config.cleanup.schedule, async () => { + await context.scheduleJob('cloudflare-lists-ips-cleanup-job', config.cleanup.schedule, async () => { let lock = null; try { - lock = await context.job.lock('cloud-flare-lists-ips-cleanup-job', { ttl: config.cleanup.lockTTL }); - await context.log('trace', '[CloudFlare] IPs cleanup job started.'); + lock = await context.job.lock('cloudflare-lists-ips-cleanup-job', { ttl: config.cleanup.lockTTL }); + await context.log('trace', '[Cloudflare Lists] IPs cleanup job started.'); // Delete IPs where the time difference between 'mtime' and 'removeAfter' exceeds the specified timespan, // indicating that deletion attempts have persisted for the entire timespan. @@ -108,18 +44,20 @@ module.exports = async (context) => { $expr: { $gt: [{ $subtract: ['$mtime', '$removeAfter'] }, timespan] } }); - await context.log('info', { stage: `[CloudFlare] Deleted ${expired.deletedCount} orphaned rules.` }); + if (expired.deletedCount) { + await context.log('info', { step: `[Cloudflare Lists] Deleted ${expired.deletedCount} orphaned rules.` }); + } } catch (err) { if (err.message !== 'locked') { context.log('error', { - stage: '[CloudFlare] Error checking orphaned ips', + step: '[Cloudflare Lists] Error checking orphaned ips', error: err, errorRaw: context.utils.Error.stringify(err) }); } } finally { lock?.unlock(); - await context.log('trace', '[CloudFlare] IPs cleanup job finished. Lock unlocked.'); + await context.log('trace', '[Cloudflare Lists] IPs cleanup job finished. Lock unlocked.'); } }); }; diff --git a/src/appmixer/cloudflare/jobs.lists.js b/src/appmixer/cloudflare/jobs.lists.js new file mode 100644 index 000000000..198a214ae --- /dev/null +++ b/src/appmixer/cloudflare/jobs.lists.js @@ -0,0 +1,69 @@ +const CloudflareAPI = require('./CloudflareAPI'); + +const getModel = (context) => require('./IPListModel')(context); + +const deleteExpiredIpsFromList = async function(context) { + + const expired = await getExpiredItems(context); + + const groups = Object.values(expired); + + const promises = groups.map(chunk => { + + const { email, apiKey, account, list } = chunk.auth; + + context.log('info', { step: '[Cloudflare Lists] removing expired IPs', ips: chunk.ips, list, account }); + + const client = new CloudflareAPI({ email, apiKey }); + // https://developers.cloudflare.com/api/operations/lists-delete-list-items + return client.callEndpoint(context, { + method: 'DELETE', action: `/accounts/${account}/rules/lists/${list}/items`, data: { items: chunk.ips } + }); + }); + + const itemsToDelete = { ids: [], lists: [] }; + + (await Promise.allSettled(promises)).forEach(async (result, i) => { + if (result.status === 'fulfilled') { + itemsToDelete.ids = itemsToDelete.ids.concat(groups[i].ips); + itemsToDelete.lists.push(groups[i]?.auth?.list); + } else { + const operations = groups[i].ips.map(item => ({ + updateOne: { + filter: { id: item.id }, update: { $set: { mtime: new Date } } + } + })); + await (context.db.collection(getModel(context).collection)).bulkWrite(operations); + } + }); + + if (itemsToDelete.ids.length) { + const deleted = await context.db.collection(getModel(context).collection) + .deleteMany({ id: { $in: itemsToDelete.ids.map(item => item.id) } }); + await context.log('info', { + step: `[Cloudflare Lists] Deleted total ${deleted.deletedCount} ips.`, + lists: itemsToDelete.lists, + itemIds: itemsToDelete.ids + }); + } +}; + +const getExpiredItems = async function(context) { + + const expired = await context.db.collection(getModel(context).collection) + .find({ removeAfter: { $lt: Date.now() } }) + .toArray(); + + return expired.reduce((res, item) => { + const key = item.auth.list; // listId + res[key] = res[key] || { ips: [] }; + res[key].auth = item.auth; + res[key].ips.push({ id: item.id }); + + return res; + }, {}); +}; + +module.exports = { + deleteExpiredIpsFromList +}; diff --git a/src/appmixer/cloudflare/lib.js b/src/appmixer/cloudflare/lib.js deleted file mode 100644 index 973129a1d..000000000 --- a/src/appmixer/cloudflare/lib.js +++ /dev/null @@ -1,36 +0,0 @@ -module.exports = { - extractIPs(expression) { - // Regular expression to match IP addresses within the curly braces - // const ipRegex = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g; - // Extracting the section of the expression that contains the IPs - - const match = expression.match(/{([^}]+)}/); - if (!match) { - // If no match for IP address block found, return empty array - return []; - } - - const ipsBlock = match[1]; // This contains "192.0.2.0 192.0.2.2 192.0.2.3" - return ipsBlock.split(' '); - }, - - getIpsFromRules(rules) { - - const ips = new Set(); - rules.forEach(rule => { - - const data = this.extractIPs(rule.expression); - data.forEach(item => ips.add(item)); - - }); - - return ips; - }, - - OUTPUT_PORT: { - SUCCESS: 'success', - FAILURE: 'failure' - } - -}; - diff --git a/src/appmixer/cloudflare/lists/AddToIPList/AddToIPList.js b/src/appmixer/cloudflare/lists/AddToIPList/AddToIPList.js index 257a18cdf..cde17d266 100644 --- a/src/appmixer/cloudflare/lists/AddToIPList/AddToIPList.js +++ b/src/appmixer/cloudflare/lists/AddToIPList/AddToIPList.js @@ -1,16 +1,16 @@ -const ZoneCloudflareClient = require('../../ZoneCloudflareClient'); +'use strict'; + const lib = require('../lib'); let attempts = 0; -const getStatus = async function(context, client, { account, id }) { +const getStatus = async function(context, { account, id }) { - context.log({ stage: 'GETTING STATUS', id, attempts }); // https://developers.cloudflare.com/api/operations/lists-get-bulk-operation-status - const { data } = await client.callEndpoint(context, { + const { data } = await lib.callEndpoint(context, { action: `/accounts/${account}/rules/lists/bulk_operations/${id}` }); - context.log({ stage: 'STATUS DATA', data }); + context.log({ step: 'getting status', data }); if (data?.result?.status === 'failed') { throw new context.CancelError(data?.result?.error || data?.errors); } @@ -19,7 +19,7 @@ const getStatus = async function(context, client, { account, id }) { attempts++; if (attempts <= 5) { await new Promise(r => setTimeout(r, 2000)); - return await getStatus(context, client, { account, id }); + return await getStatus(context, { account, id }); } else { throw new context.CancelError(data.errors); } @@ -28,7 +28,6 @@ const getStatus = async function(context, client, { account, id }) { return data.result; }; - module.exports = { async receive(context) { @@ -36,10 +35,8 @@ module.exports = { const { accountsFetch, listFetch } = context.properties; const { account, list, ips, ttl } = context.messages.in.content; - const client = new ZoneCloudflareClient({ email, apiKey }); - if (accountsFetch || listFetch) { - return await lib.fetchInputs(context, client, { account, listFetch, accountsFetch }); + return await lib.fetchInputs(context, { account, listFetch, accountsFetch }); } const ipsList = ips.AND; @@ -49,13 +46,14 @@ module.exports = { } // https://developers.cloudflare.com/api/operations/lists-create-list-items - const { data } = await client.callEndpoint(context, { + const { data } = await lib.callEndpoint(context, { method: 'POST', action: `/accounts/${account}/rules/lists/${list}/items`, data: ipsList }); - const status = await getStatus(context, client, { id: data.result.operation_id, account }); + + const status = await getStatus(context, { id: data.result.operation_id, account }); if (status.error) { throw new context.CancelError(status.error); @@ -63,7 +61,7 @@ module.exports = { if (ttl) { const removeAfter = new Date().getTime() + ttl * 1000; - const listItemsWithIds = await client.findIdsForIPs({ context, client, ips: ipsList, account, list }); + const listItemsWithIds = await lib.findIdsForIPs({ context, ips: ipsList, account, list }); const dbItems = listItemsWithIds.map(item => { const { ip, id } = item; diff --git a/src/appmixer/cloudflare/lists/AddToIPList/component.json b/src/appmixer/cloudflare/lists/AddToIPList/component.json index 267aa3d4f..e5efe8fcb 100644 --- a/src/appmixer/cloudflare/lists/AddToIPList/component.json +++ b/src/appmixer/cloudflare/lists/AddToIPList/component.json @@ -4,7 +4,7 @@ "description": "Appends new items to the IP list. Maximum capacity if the list is 1000 items.", "private": false, "auth": { - "service": "appmixer:cloudflare:lists" + "service": "appmixer:cloudflare" }, "quota": { "manager": "appmixer:cloudflare", diff --git a/src/appmixer/cloudflare/lists/RemoveFromIPList/RemoveFromIPList.js b/src/appmixer/cloudflare/lists/RemoveFromIPList/RemoveFromIPList.js index 7100c011b..3b46c57eb 100644 --- a/src/appmixer/cloudflare/lists/RemoveFromIPList/RemoveFromIPList.js +++ b/src/appmixer/cloudflare/lists/RemoveFromIPList/RemoveFromIPList.js @@ -1,15 +1,14 @@ -const ZoneCloudflareClient = require('../../ZoneCloudflareClient'); +const lib = require('../lib'); let attempts = 0; -const getStatus = async function(context, client, { account, id }) { +const getStatus = async function(context, { account, id }) { - context.log({ stage: 'GETTING STATUS', id, attempts }); // https://developers.cloudflare.com/api/operations/lists-get-bulk-operation-status - const { data } = await client.callEndpoint(context, { + const { data } = await lib.callEndpoint(context, { action: `/accounts/${account}/rules/lists/bulk_operations/${id}` }); - context.log({ stage: 'STATUS DATA', data }); + context.log({ step: 'getting status', data }); if (data?.result?.status === 'failed') { throw new context.CancelError(data?.result?.error || data?.errors); } @@ -18,7 +17,7 @@ const getStatus = async function(context, client, { account, id }) { attempts++; if (attempts <= 10) { await new Promise(r => setTimeout(r, 1000)); - return await getStatus(context, client, { account, id }); + return await getStatus(context, { account, id }); } else { throw new context.CancelError(data.errors); } @@ -30,26 +29,24 @@ const getStatus = async function(context, client, { account, id }) { module.exports = { async receive(context) { - const { apiKey, email } = context.auth; const { account, list, ips } = context.messages.in.content; - const client = new ZoneCloudflareClient({ email, apiKey }); const ipsList = ips.AND; - const listItemsWithIds = await client.findIdsForIPs({ context, client, ips: ipsList, account, list }); + const listItemsWithIds = await lib.findIdsForIPs({ context, ips: ipsList, account, list }); if (!listItemsWithIds.length) { return context.sendJson({ id: 'N/A', status: 'Completed', completed: new Date().toISOString() }, 'out'); } - context.log({ stage: 'removing IPs ', items: listItemsWithIds }); + context.log({ step: 'removing IPs ', items: listItemsWithIds }); // https://developers.cloudflare.com/api/operations/lists-create-list-items - const { data } = await client.callEndpoint(context, { + const { data } = await lib.callEndpoint(context, { method: 'DELETE', action: `/accounts/${account}/rules/lists/${list}/items`, data: { items: listItemsWithIds.map(item => ({ id: item.id })) } }); - const status = await getStatus(context, client, { id: data.result.operation_id, account }); + const status = await getStatus(context, { id: data.result.operation_id, account }); if (status.error) { throw new context.CancelError(status.error); diff --git a/src/appmixer/cloudflare/lists/RemoveFromIPList/component.json b/src/appmixer/cloudflare/lists/RemoveFromIPList/component.json index fffbe3ced..4051592a2 100644 --- a/src/appmixer/cloudflare/lists/RemoveFromIPList/component.json +++ b/src/appmixer/cloudflare/lists/RemoveFromIPList/component.json @@ -4,7 +4,7 @@ "description": "Removes IPs from the IP list.", "private": false, "auth": { - "service": "appmixer:cloudflare:lists" + "service": "appmixer:cloudflare" }, "quota": { "manager": "appmixer:cloudflare", diff --git a/src/appmixer/cloudflare/lists/auth.js b/src/appmixer/cloudflare/lists/auth.js deleted file mode 100644 index 3af59f7c6..000000000 --- a/src/appmixer/cloudflare/lists/auth.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict'; - -const ZoneCloudflareClient = require('../ZoneCloudflareClient'); - -module.exports = { - - type: 'apiKey', - - definition: { - - accountNameFromProfileInfo: 'account', - - auth: { - email: { - type: 'text', - name: 'Email', - tooltip: 'Enter your "Email".' - }, - apiKey: { - type: 'text', - name: 'Global API Key', - tooltip: 'Enter your "Global API Key".' - } - }, - - requestProfileInfo: async function(context) { - - const { email } = context; - - if (email) { - return { account: email }; - } - }, - - validate: async function(context) { - - const { email, apiKey } = context; - const client = new ZoneCloudflareClient({ email, apiKey }); - const { data } = await client.verifyGlobalApiKey(context); - return data.success || false; - - } - } -}; diff --git a/src/appmixer/cloudflare/lists/lib.js b/src/appmixer/cloudflare/lists/lib.js index f25d5f014..56f317e68 100644 --- a/src/appmixer/cloudflare/lists/lib.js +++ b/src/appmixer/cloudflare/lists/lib.js @@ -1,33 +1,83 @@ -module.exports = { - async fetchInputs(context, client, { account, accountsFetch, listFetch }) { +const callEndpoint = async function(context, { + action, + method = 'GET', + data, + params +}) { - try { - if (accountsFetch) { - const { data } = await client.callEndpoint(context, { action: '/accounts?per_page=50' }); - const items = data.result.map(item => { - return { - label: item.name, - value: item.id - }; - }); - - return context.sendJson(items, 'out'); - } + return context.httpRequest({ + method, + url: `https://api.cloudflare.com/client/v4${action}`, + headers: { + 'Content-Type': 'application/json', + 'X-Auth-Email': context.auth.email, + 'X-Auth-Key': context.auth.apiKey + }, + data, + params + }); +}; + +const fetchInputs = async function(context, { account, accountsFetch, listFetch }) { + + try { + if (accountsFetch) { + const { data } = await callEndpoint(context, { action: '/accounts?per_page=50' }); + const items = data.result.map(item => { + return { + label: item.name, + value: item.id + }; + }); + + return context.sendJson(items, 'out'); + } + + if (listFetch) { + const { data } = await callEndpoint(context, { action: `/accounts/${account}/rules/lists` }); + const items = data.result.map(item => { + return { + label: item.name, + value: item.id + }; + }); + + return context.sendJson(items, 'out'); + } + + } catch (e) { + return context.sendJson([], 'out'); + } +}; + +const findIdsForIPs = async function({ context, ips = [], account, list }) { - if (listFetch) { - const { data } = await client.callEndpoint(context, { action: `/accounts/${account}/rules/lists` }); - const items = data.result.map(item => { - return { - label: item.name, - value: item.id - }; - }); + const result = []; + for (let ipItem of ips) { - return context.sendJson(items, 'out'); + try { + const { data } = await callEndpoint(context, { + method: 'GET', + action: `/accounts/${account}/rules/lists/${list}/items`, + params: { + per_page: 1, + search: ipItem.ip + } + }); + if (data?.result[0] && data?.result.length === 1) { + result.push({ ...data.result[0] }); } - } catch (e) { - return context.sendJson([], 'out'); + } catch (err) { + context.log({ step: `Invalid IP, IP ${ipItem} hasn't been found in the list ${list}` }); } } + + return result; +}; + +module.exports = { + findIdsForIPs, + fetchInputs, + callEndpoint }; diff --git a/src/appmixer/cloudflare/package-lock.json b/src/appmixer/cloudflare/package-lock.json new file mode 100644 index 000000000..49189ba94 --- /dev/null +++ b/src/appmixer/cloudflare/package-lock.json @@ -0,0 +1,23 @@ +{ + "name": "appmixer.cloudflare", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "appmixer.cloudflare", + "version": "1.0.0", + "dependencies": { + "ip-address": "^10.0.1" + } + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "engines": { + "node": ">= 12" + } + } + } +} diff --git a/src/appmixer/cloudflare/plugin.js b/src/appmixer/cloudflare/plugin.js index 03088a109..c208c6faf 100644 --- a/src/appmixer/cloudflare/plugin.js +++ b/src/appmixer/cloudflare/plugin.js @@ -1,11 +1,7 @@ 'use strict'; module.exports = async context => { - context.log('info', '[CloudFlare] Initializing CloudFlare plugin.'); await require('./routes')(context); - - context.log('info', '[CloudFlare] Scheduling CloudFlare jobs.'); await require('./jobs')(context); - - context.log('info', '[CloudFlare] CloudFlare plugin initialized.'); + context.log('info', '[Cloudflare Lists] Cloudflare plugin successfully initialized.'); }; diff --git a/src/appmixer/cloudflare/service.json b/src/appmixer/cloudflare/service.json index e83a1161d..42246fa23 100644 --- a/src/appmixer/cloudflare/service.json +++ b/src/appmixer/cloudflare/service.json @@ -1,8 +1,8 @@ { "name": "appmixer.cloudflare", - "label": "Salt CloudFlare", + "label": "Cloudflare Lists", "category": "applications", - "description": "CloudFlare provides content delivery network services, cloud cybersecurity, DDoS mitigation, wide area network services, reverse proxies, Domain Name Service, and ICANN-accredited domain registration services.", + "description": "Cloudflare Lists integrations allows you to work with IP lists.", "icon": "data:image/png;base64,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", - "version": "1.0.0" + "version": "1.0.5" } diff --git a/src/appmixer/cloudflareWAF/CloudflareAPI.js b/src/appmixer/cloudflareWAF/CloudflareAPI.js new file mode 100644 index 000000000..4a8c540ff --- /dev/null +++ b/src/appmixer/cloudflareWAF/CloudflareAPI.js @@ -0,0 +1,120 @@ +module.exports = class CloudflareAPI { + + constructor({ email, apiKey, zoneId, token }) { + this.email = email; + this.zoneId = zoneId; + this.email = email; + this.apiKey = apiKey; + this.token = token; + } + + getHeaders() { + if (this.token) { + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.token}` + }; + } + + return { + 'Content-Type': 'application/json', + 'X-Auth-Email': this.email, + 'X-Auth-Key': this.apiKey + }; + } + + async verify(context) { + + const headers = this.getHeaders(); + + return context.httpRequest({ + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/user/tokens/verify', + headers + }); + } + + async verifyGlobalApiKey(context) { + + const headers = this.getHeaders(); + + return context.httpRequest({ + method: 'GET', + url: 'https://api.cloudflare.com/client/v4/accounts', + headers + }); + } + + async callEndpoint(context, { + action, + method = 'GET', + data, + params + }) { + + const headers = this.getHeaders(); + + return context.httpRequest({ + method, + url: `https://api.cloudflare.com/client/v4${action}`, + headers, + data, + params + }); + } + + // WAF + /** + * https://developers.cloudflare.com/api/operations/getZoneRuleset + */ + listZoneRulesets(context) { + + return context.httpRequest({ + method: 'GET', + url: `https://api.cloudflare.com/client/v4/zones/${this.zoneId}/rulesets`, + headers: this.getHeaders() + }).then(resp => resp.data.result); + } + + async getRules(context, rulesetId) { + + const response = await context.httpRequest({ + method: 'GET', + url: `https://api.cloudflare.com/client/v4/zones/${this.zoneId}/rulesets/${rulesetId}`, + headers: this.getHeaders() + }); + + return response.data; + } + + async createRulesetAndBlockRule(context, ips) { + + const { data } = await context.httpRequest({ + method: 'POST', + url: `https://api.cloudflare.com/client/v4/zones/${this.zoneId}/rulesets`, + headers: this.getHeaders(), + data: { + kind: 'zone', + phase: 'http_request_firewall_custom', + name: 'Http Request Firewall Custom Ruleset', + description: 'Created by Salt Security\'s Cloudflare Integration. Firewall rules of identified attackers will be added to this ruleset.', + rules: [this.getBlockRule(1, ips)] + } + }); + + return data; + } + + // https://developers.cloudflare.com/api/resources/rulesets/subresources/rules/ + createBlockRule(context, { rulesetId, rule }) { + const url = `https://api.cloudflare.com/client/v4/zones/${this.zoneId}/rulesets/${rulesetId}/rules`; + const headers = this.getHeaders(); + return context.httpRequest.post(url, rule, { headers }).then(resp => resp.data); + } + + updateBlockRule(context, rulesetId, rule) { + const url = `https://api.cloudflare.com/client/v4/zones/${this.zoneId}/rulesets/${rulesetId}/rules/${rule.id}`; + const headers = this.getHeaders(); + return context.httpRequest.patch(url, rule, { headers }).then(resp => resp.data); + } +}; diff --git a/src/appmixer/cloudflareWAF/RulesIPsModel.js b/src/appmixer/cloudflareWAF/RulesIPsModel.js new file mode 100644 index 000000000..0fa33c80d --- /dev/null +++ b/src/appmixer/cloudflareWAF/RulesIPsModel.js @@ -0,0 +1,36 @@ +'use strict'; + +module.exports = context => { + + class RulesIPsModel extends context.db.Model { + + static get collection() { + + return 'rulesIPsModel'; + } + + static get idProperty() { + + return 'id'; + } + + static get properties() { + + return [ + 'id', + 'ip', + 'ruleId', + 'rulesetId', + 'zoneId', + 'removeAfter', + 'auth', + 'mtime' + ]; + } + } + + RulesIPsModel.createSettersAndGetters(); + + return RulesIPsModel; +}; + diff --git a/src/appmixer/cloudflareWAF/auth.js b/src/appmixer/cloudflareWAF/auth.js new file mode 100644 index 000000000..242fd94bf --- /dev/null +++ b/src/appmixer/cloudflareWAF/auth.js @@ -0,0 +1,38 @@ +'use strict'; + +const CloudflareAPI = require('./CloudflareAPI'); + +module.exports = { + + type: 'apiKey', + + definition: { + + accountNameFromProfileInfo: 'account', + + auth: { + apiToken: { + type: 'text', + name: 'API Token', + tooltip: 'Manage access and permissions for your accounts, sites, and products".' + } + }, + + requestProfileInfo: async function(context) { + + const { apiToken } = context; + + const threshold = 10; + if (apiToken.length > threshold) { + return { account: apiToken.slice(0, 5) + ' ... ' + apiToken.slice(-3) }; + } + }, + + validate: async function(context) { + + const client = new CloudflareAPI({ token: context.apiToken }); + const { data } = await client.verify(context); + return data.success || false; + } + } +}; diff --git a/src/appmixer/cloudflareWAF/bundle.json b/src/appmixer/cloudflareWAF/bundle.json new file mode 100644 index 000000000..3c2410e1c --- /dev/null +++ b/src/appmixer/cloudflareWAF/bundle.json @@ -0,0 +1,5 @@ +{ + "name": "appmixer.cloudflareWAF", + "version": "1.0.0", + "changelog": [] +} diff --git a/src/appmixer/cloudflareWAF/config.js b/src/appmixer/cloudflareWAF/config.js new file mode 100644 index 000000000..5dbf26cd9 --- /dev/null +++ b/src/appmixer/cloudflareWAF/config.js @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = context => { + + return { + ipDeleteJob: { + + /** Default: every 10sec */ + schedule: context.config.ipDeleteJobSchedule || '*/10 * * * * *', + /** Lock TTL, default: 10sec */ + lockTTL: context.config.ipDeleteJobLockTTL || 10000 + }, + cleanup: { + /** Default: every hour */ + schedule: context.config.cleanupJobSchedule || '0 0 * * * *', + /** Lock TTL, default: 10sec */ + lockTTL: context.config.cleanupJobLockTTL || 10000 + } + }; +}; diff --git a/src/appmixer/cloudflareWAF/jobs.js b/src/appmixer/cloudflareWAF/jobs.js new file mode 100644 index 000000000..de6cdb2e7 --- /dev/null +++ b/src/appmixer/cloudflareWAF/jobs.js @@ -0,0 +1,61 @@ +const wafJobs = require('./jobs.waf'); + +module.exports = async (context) => { + + const RulesIPsModel = require('./RulesIPsModel')(context); + + const config = require('./config')(context); + + await context.scheduleJob('cloud-flare-waf-ips-delete-job', config.ipDeleteJob.schedule, async () => { + + try { + const lock = await context.job.lock('cloud-flare-waf-rule-block-ips-delete-job', { ttl: config.ipDeleteJob.lockTTL }); + await context.log('trace', '[Cloudflare WAF] rule delete job started.'); + + try { + await wafJobs.deleteExpireIps(context); + } finally { + lock.unlock(); + await context.log('trace', '[Cloudflare WAF] rule delete job finished. Lock unlocked.'); + } + } catch (err) { + if (err.message !== 'locked') { + context.log('error', { + stage: '[Cloudflare WAF] Error checking rules to delete', + error: err, + errorRaw: context.utils.Error.stringify(err) + }); + } + } + }); + + // Self-healing job to remove rules that have created>mtime. These rules are stuck in the system and should be removed. + await context.scheduleJob('cloud-flare-waf-ips-cleanup-job', config.cleanup.schedule, async () => { + + let lock = null; + try { + lock = await context.job.lock('cloud-flare-waf-ips-cleanup-job', { ttl: config.cleanup.lockTTL }); + + // Delete IPs where the time difference between current date and 'removeAfter' exceeds the specified timespan, + // indicating that IPs cannot be deleted from the rules. + const timespan = 30 * 60 * 1000; // 30 min + const expired = await context.db.collection(RulesIPsModel.collection).deleteMany({ + removeAfter: { $lt: new Date(Date.now() - timespan).valueOf() } + }); + + if (expired.deletedCount) { + await context.log('info', { stage: `[Cloudflare WAF] Deleted ${expired.deletedCount} orphaned rules.` }); + } + } catch (err) { + if (err.message !== 'locked') { + context.log('error', { + stage: '[Cloudflare WAF] Error checking orphaned ips', + error: err, + errorRaw: context.utils.Error.stringify(err) + }); + } + } finally { + lock?.unlock(); + } + }); +}; diff --git a/src/appmixer/cloudflareWAF/jobs.waf.js b/src/appmixer/cloudflareWAF/jobs.waf.js new file mode 100644 index 000000000..a095ee339 --- /dev/null +++ b/src/appmixer/cloudflareWAF/jobs.waf.js @@ -0,0 +1,184 @@ +const CloudflareAPI = require('./CloudflareAPI'); +const { Address4, Address6 } = require('ip-address'); + +const getModel = (context) => require('./RulesIPsModel')(context); + +const deleteExpireIps = async function(context) { + + const expired = await getExpiredItems(context); + + if (expired.length) { + await context.log('info', { step: '[Cloudflare WAF] expired.', data: sanitizeItems(expired) }); + } + + const rulesToUpdate = await retrieveRulesForUpdate(context, expired); + if (rulesToUpdate.length) { + await context.log('info', { step: '[Cloudflare WAF] rules to update.', data: sanitizeItems(rulesToUpdate) }); + } + + const dbItemsToDelete = await updateRules(context, rulesToUpdate); + await deleteDBItems(context, dbItemsToDelete); +}; + +const retrieveRulesForUpdate = async function(context, expired = []) { + + const rulesToUpdate = []; + const getRulePromises = expired.map(item => { + const { auth, zoneId, rulesetId } = item; + const client = new CloudflareAPI({ token: auth.token, zoneId }); + return client.getRules(context, rulesetId); + }); + + (await Promise.allSettled(getRulePromises)).forEach((result, i) => { + + const item = expired[i]; + + if (result.status === 'fulfilled') { + + const { result: { rules = [] } } = result.value; + const rule = rules.find(r => r.id === item.ruleId); + if (rule) { + rulesToUpdate.push({ + ...item, + rule: removeIpsFromRule(rule, item?.ips?.map(i => i.ip)) + }); + } + } else { + context.log('info', { + step: '[Cloudflare WAF] Unable to retrieve rule for expired item', + data: sanitizeItems([item]) + }); + } + }); + + return rulesToUpdate; +}; + +const updateRules = async function(context, rulesToUpdate) { + + let dbItemsToDelete = []; + const updatePromises = rulesToUpdate.map(ruleToUpdate => { + + const { zoneId, rulesetId, auth, rule } = ruleToUpdate; + + const client = new CloudflareAPI({ token: auth.token, zoneId }); + + return client.updateBlockRule(context, rulesetId, rule); + }); + + (await Promise.allSettled(updatePromises)).forEach(async (result, i) => { + + const item = rulesToUpdate[i]; + const dbItems = item?.ips?.map(i => i.id) || []; + + if (result.status === 'fulfilled') { + dbItemsToDelete = dbItemsToDelete.concat(dbItems); + } else { + context.log('info', { + step: '[Cloudflare WAF] Unable to delete IPs from rule.', + data: sanitizeItems([item]) + }); + } + }); + + return dbItemsToDelete; +}; + +const deleteDBItems = async function(context, ids = []) { + + if (ids.length) { + await context.db.collection(getModel(context).collection) + .deleteMany({ id: { $in: ids } }); + } +}; + +const getExpiredItems = async function(context) { + + const expired = await context.db.collection(getModel(context).collection) + .find({ removeAfter: { $lt: Date.now() } }) + .toArray(); + + if (expired.length === 0) return []; + + const groupedByRules = expired.reduce((res, item) => { + const key = item.ruleId; + res[key] = res[key] || { ips: [] }; + res[key].ips.push({ ip: item.ip, id: item.id }); + res[key].auth = item.auth; + res[key].id = item.id; + res[key].zoneId = item.zoneId; + res[key].ruleId = item.ruleId; + res[key].rulesetId = item.rulesetId; + return res; + }, {}); + + return Object.values(groupedByRules); +}; + +function removeIpsFromRule(rule, ipsToRemove = []) { + const data = extractIPs(rule.expression); + const ips = []; + data.forEach(ip => { + if (!ipsToRemove.includes(ip)) { + ips.push(ip); + } + }); + + const expression = getBlockExpression(ips); + + return { ...rule, expression }; +} + +function extractIPs(expression) { + + // Regular expression to match IP addresses within the curly braces + // const ipRegex = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g; + // Extracting the section of the expression that contains the IPs + + const match = expression.match(/{([^}]+)}/); + if (!match) { + // If no match for IP address block found, return empty array + return []; + } + + const ipsBlock = match[1]; // This contains "192.0.2.0 192.0.2.2 192.0.2.3" + return ipsBlock.split(' '); +} + +function getBlockExpression(ips) { + const ipv4 = ips.filter(ip => Address4.isValid(ip)); + const ipv6 = ips.filter(ip => Address6.isValid(ip)); + const formattedIpv6 = ipv6.length > 0 ? ipv6.map(ip => removeInterfaceIdentifierAndAddCidr(ip)) : ipv6; + const allIps = [...ipv4, ...formattedIpv6].sort(); + + return `(ip.src in {${allIps.join(' ')}})`; +} + +function removeInterfaceIdentifierAndAddCidr(ip) { + const networkPrefix = ip + .split(':') + .slice(0, 4) + .join(':'); + return `${networkPrefix}::/64`; +} + +/** + * remove sensitive info from objects + * @param items + * @returns {Omit<*, 'auth'>[]} + */ +function sanitizeItems(items = []) { + + return items.map(item => { + // eslint-disable-next-line no-unused-vars + const { auth, ...info } = item; + return info; + }); +} + +module.exports = { + deleteExpireIps, + getBlockExpression, + extractIPs, + removeIpsFromRule +}; diff --git a/src/appmixer/cloudflareWAF/package-lock.json b/src/appmixer/cloudflareWAF/package-lock.json new file mode 100644 index 000000000..49189ba94 --- /dev/null +++ b/src/appmixer/cloudflareWAF/package-lock.json @@ -0,0 +1,23 @@ +{ + "name": "appmixer.cloudflare", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "appmixer.cloudflare", + "version": "1.0.0", + "dependencies": { + "ip-address": "^10.0.1" + } + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "engines": { + "node": ">= 12" + } + } + } +} diff --git a/src/appmixer/cloudflareWAF/package.json b/src/appmixer/cloudflareWAF/package.json new file mode 100644 index 000000000..93228288f --- /dev/null +++ b/src/appmixer/cloudflareWAF/package.json @@ -0,0 +1,8 @@ +{ + "name": "appmixer.cloudflare", + "version": "1.0.0", + "author": "Salt", + "dependencies": { + "ip-address": "10.0.1" + } +} diff --git a/src/appmixer/cloudflareWAF/plugin.js b/src/appmixer/cloudflareWAF/plugin.js new file mode 100644 index 000000000..dfe28f968 --- /dev/null +++ b/src/appmixer/cloudflareWAF/plugin.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = async context => { + await require('./routes')(context); + await require('./jobs')(context); + context.log('info', '[Cloudflare WAF] Cloudflare WAF plugin successfully initialized.'); +}; diff --git a/src/appmixer/cloudflareWAF/quota.js b/src/appmixer/cloudflareWAF/quota.js new file mode 100644 index 000000000..0d1dd297e --- /dev/null +++ b/src/appmixer/cloudflareWAF/quota.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + + rules: [ + // CloudFlare quota is 1200 per 5 minutes + { + limit: 80, // components call multiple api call - add/remove + get status + (optional: get id) + window: 1000 * 60, + throttling: 'window-sliding', + queueing: 'fifo', + resource: 'requests' + } + ] +}; diff --git a/src/appmixer/cloudflareWAF/routes.js b/src/appmixer/cloudflareWAF/routes.js new file mode 100644 index 000000000..00a35aa3f --- /dev/null +++ b/src/appmixer/cloudflareWAF/routes.js @@ -0,0 +1,29 @@ +'use strict'; + +module.exports = (context, options) => { + + const RulesIPsModel = require('./RulesIPsModel')(context); + + context.http.router.register({ + method: 'POST', path: '/block-ip-rules', options: { + handler: async req => { + + const items = req.payload.items; + const operations = items.map(item => ({ + updateOne: { + filter: { ip: item.ip, zoneId: item.zoneId }, update: { $set: item }, upsert: true + } + })); + return await (context.db.collection(RulesIPsModel.collection)).bulkWrite(operations); + } + } + }); + + context.http.router.register({ + method: 'GET', path: '/block-ip-rules', options: { + handler: async req => { + return RulesIPsModel.find({}); + } + } + }); +}; diff --git a/src/appmixer/cloudflare/lists/module.json b/src/appmixer/cloudflareWAF/service.json similarity index 95% rename from src/appmixer/cloudflare/lists/module.json rename to src/appmixer/cloudflareWAF/service.json index 26a97504f..37d10be19 100644 --- a/src/appmixer/cloudflare/lists/module.json +++ b/src/appmixer/cloudflareWAF/service.json @@ -1,7 +1,8 @@ { - "name": "appmixer.cloudflare.lists", - "label": "CloudFlare Lists", + "name": "appmixer.cloudflareWAF", + "label": "Cloudflare WAF", "category": "applications", - "description": "CloudFlare List integrations allows you to work with IP lists.", - "icon": "data:image/png;base64,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" + "description": "Cloudflare WAF integrations allows you to actively block attacker IP by using Cloudflare WAF infrastructure.", + "icon": "data:image/png;base64,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", + "version": "1.0.5" } diff --git a/src/appmixer/cloudflareWAF/waf/CreateCustomRules/CreateCustomRules.js b/src/appmixer/cloudflareWAF/waf/CreateCustomRules/CreateCustomRules.js new file mode 100644 index 000000000..d188810be --- /dev/null +++ b/src/appmixer/cloudflareWAF/waf/CreateCustomRules/CreateCustomRules.js @@ -0,0 +1,82 @@ +const CloudflareAPI = require('../../CloudflareAPI'); +const lib = require('../lib'); +const crypto = require('crypto'); + +module.exports = { + async receive(context) { + + const { apiToken } = context.auth; + const { zoneId } = context.properties; + const { ips, ttl } = context.messages.in.content; + + if (ips.length === 0) { + return context.sendJson([], 'out'); + } + + const parsedIps = ips.split(/\s+|,/); // Split by comma or any whitespace + const client = new CloudflareAPI({ zoneId, token: apiToken }); + + const ruleset = (await client.listZoneRulesets(context)) + .find(ruleset => ruleset.kind === 'zone' && ruleset.phase === 'http_request_firewall_custom'); + + let resultRules = []; // all affected rules - created or updated + if (!ruleset) { + const { data } = await client.createRulesetAndBlockRule(context, parsedIps); + resultRules = data?.result?.rules || []; + } else { + const { result: { rules = [] } } = await client.getRules(context, ruleset.id); + resultRules = rules; + + const rulesToUpdate = lib.prepareRulesForCreateOrUpdate(parsedIps, rules); + + const promises = rulesToUpdate.map(rule => { + return rule.id ? + client.updateBlockRule(context, ruleset.id, rule) : + client.createBlockRule(context, { rulesetId: ruleset.id, rule }); + }); + + (await Promise.allSettled(promises)).forEach(result => { + updatedOrCreatedRules = result?.value?.result?.rules || []; + updatedOrCreatedRules.forEach(rule => { + const index = resultRules.findIndex(r => r.id === rule.id); + if (index !== -1) { + resultRules[index] = rule; + } else { + resultRules.push(rule); + } + }); + }); + } + + const updatedIps = lib.findIpsInRules(resultRules, parsedIps); + const updatedIpsArray = Object.entries(updatedIps).map(([ip, { id }]) => ({ ip, ruleId: id })); + + if (ttl) { + + const removeAfter = new Date().getTime() + ttl * 1000; + const dbItems = updatedIpsArray.map(item => { + const { ip, ruleId } = item; + return { + id: crypto.randomUUID(), + ip, + ruleId, + rulesetId: ruleset.id, + zoneId, + removeAfter, + auth: { + token: apiToken + } + }; + }); + + await context.callAppmixer({ + endPoint: '/plugins/appmixer/cloudflareWAF/block-ip-rules', + method: 'POST', + body: { items: dbItems } + }); + } + + return context.sendJson(updatedIpsArray, 'out'); + + } +}; diff --git a/src/appmixer/cloudflareWAF/waf/CreateCustomRules/component.json b/src/appmixer/cloudflareWAF/waf/CreateCustomRules/component.json new file mode 100644 index 000000000..1691491b1 --- /dev/null +++ b/src/appmixer/cloudflareWAF/waf/CreateCustomRules/component.json @@ -0,0 +1,69 @@ +{ + "name": "appmixer.cloudflareWAF.waf.CreateCustomRules", + "label": "Block IPs", + "description": "Create custom rules in waf", + "private": false, + "version": "1.0.5", + "dependencies": { + "service": "1.0.5" + }, + "auth": { + "service": "appmixer:cloudflareWAF" + }, + "inPorts": [ + { + "name": "in", + "schema": { + "type": "object", + "properties": { + "ips": { "type": "string" }, + "ttl": { "type": "number" } + }, + "required": ["ips", "ttl"] + }, + "inspector": { + "inputs": { + "ips": { + "type": "text", + "label": "IP address", + "tooltip": "List of IP address which will be blocked by adding a blocking custom rule to the waf. The values must be separated by a comma, space or newline.", + "index": 1 + }, + "ttl": { + "type": "number", + "label": "TTL", + "index": 2, + "tooltip": "Time to live in seconds. The IP(s) will be automatically removed from a rule after this time. If not set, the the IP(s) will remain permanently." + } + } + } + } + ], + "properties": { + "schema": { + "type": "object", + "properties": { + "zoneId": { + "type": "string" + } + }, + "required": [ + "zoneId" + ] + }, + "inspector": { + "inputs": { + "zoneId": { + "type": "text", + "label": "Zone Id", + "tooltip": "Zone Id", + "index": 3 + } + } + } + }, + "outPorts": [ + { "name": "out" } + ], + "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAARgAAAEYCAYAAACHjumMAAAACXBIWXMAACxLAAAsSwGlPZapAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAA7FSURBVHgB7d3PbxxnGcDxZ9Z2QxBNN7ciKjo5teUS51aJQyZBlN6StHDoKQ6o4ujkALeq6xsShzj/AFmfekAlyQGpIBFP1YIqVSLuqQiKMgXUH6hSneKWUts7PM/MeG3vvvtj1rsz++P7kTbejMfj8ezMM8/7vO/MiAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgDHhCTDD4ppf3RJZ1Ldn9bUY60sPiqqkr4M29bVhXxsir8+JhN+oRRuCrggwmEn/qfmBfnlZ0uBSlQHowRPpl1BfNwg2bgQYzIwsW1nWt1dlwKDSRajZz9qJWlQXNBFgMBM+q/lLFc1YNAj4MlrhvMiV47UoEhBgMN3+W/P9HZGb+jaQYtUerkUrMuMIMJhaWZ3llgy/OdSvDc1mLs1yNlMRYAptrfhWa1mX8oKLWdzVdbAsSmYUAQZTRzOXl+NYVmUMWM3HgsxWzV+UGUQTCVPFMpdxCS4HWZf2nMi5WWsuEWAwNbKay7qMKQsy2yJnTtaiTZkRNJEwFazO4aW9RWPLmkvzadF5ZhBgMBU0M6gVMMZlGIIHNf+qzAiaSJh4Nohu3LOXFps7IqdmoalEBoOJV0mvKZok1QWR6zIDCDCYaJa9TEjT6BBd56VZGB8zL8AYsYPuK5GL2qV7uiVwvKP/Dx/Wl3egaZFdXzSRdtKLLqe6HkMNBmPhwO0Tgl7z6k5b1wC0sp3eu2WSe2WmvhZDBoPSaXCxekTfZ3JrXuxqIPLSm0BNMqvFXNSvdZlSBBiUJrs/S6hvT0tOk1h3cWlMeYChyIvSbKVdy7mDyzTx0lt1Ti0CDEphFyRKevaeddWv/rY+tYVeirwonPUU2RXG09LMOYpK9TH5+vIb9jba/dPPN+Tfbyd1pcauPGh4Es01ZGN+Sza8K+FE1puowaBw22mB1hfIQ2eXk6/xg/f8xsdv+81vVNLmRazdZduP6Os3wUbckNcXtmXVeyGMZELQRELhKumNt6Hm/KeTr/En3R9KYI9T0Q23vH1M7muwWd/+dRDIBCDAoFCfas9RnD4qZObNP/GMeNpEMvEHf+z753T7BZrZrO/cCm7GrwS+jDECDAo1T3Bpml98Pn2zvSWNT/I/VqkRy5JmNPe+fDUY2yIxAQaFisu9R+7YsOLu/JPPJO/jAYLLAdWKJ9e3bwVjecEnAQYowfziD5vvGx/23zzqJI6ltvNqMHa3rCDAoFCV9HGrM82yl4XTz+9P2HxPhkG7tZfGLcgQYFAYG//SoHs66ZreK+6aeGdLhsWCzDg1lxhoh5GxR3VozeWCpFdIBwJZePrHcuwHLx2atvP7FyT+/CMZJm9Xzi38KAylZAQYDF2eWy/MkgWtuxy78Mu26bt//oU03v+dDFm08EDOlD0CmCYShsaujs5uvWCPDgkETUnm4ggupvLtZ2UE/K9OlH8zKzIYDAXXF7lZQdcCy96I3U4af6nL7rtrMmSbmsWcKjOLIYPBkVmtZUfkHsFln/e1E0kx9/hPf9szuJjKk0sy99RlGbJq2VkMGQyOZNwzFzvQ7dVzvkceO/qyjp+QyiPfksqj3+krqLjEX2ix98F7dvGjFn4/Tid+kRWAt7ckttcXuQrCmw89F56UkhBgcCSavdwvIrjYdTvHnj3c+3Kwq3cWWaDZvftiEnS6KbNHids1YGB206iiMhfvZHswiTf/dXieGQk4zQzGspzt3mNoYi+5sVcoJSCDwUCsaaR1l/syYfpuMvUZrCoH59PlWtNo/onvy0Dsosd/vKaB8+9p4Dj4EsnbNGrSgzxceC48JyUgg8FA7FnQk3h2ir/8LHn11JIddbLb8v/tt37Vd89Rc50+2Uh6kI540WPn5Zd4BTsZDHKze7romelTQVfHL7/SPchoZrLz1ksjCywHaXf1yTK6q+mmRm5z3Ky7L/+787PO39T6yc76i4UEl0S1nNtkEGAwiEDQU0ObWbvRW+3fsODyxrWhX3/Uzc5OOcMICDDIrTLjzzLKo/HRu4cnaKE2CS7bw7uCepwRYJAb99TtX2tBeefNcoLL/Hw59+EhwAAjVHn0qeb7hvUUFdgsOmSznOd4E2CAEWqOk7ExLv98TUqyWdYFjwQYYEQsuNh1Sabx4ZulZS+eSEFdVe0IMMjN4766fZl7fH8MTDz8G0r1bTeWO1ISRvIit22RS3MUenua/+5Pmu8bC1WRbwYyCpXPP9iMP/1rxybQsfnyMhgAAAAAAAAAAAAAAApU+g2ngiDwReYWReKqro725e9uhmH5j7x00XW1e2roulb8dEqyvpGuLuMMJkz2WbbeI8X2vVKfhDhtSgkw6YdbWdagYs9s6XAjHK8u0ljRDzzqY3lLOv/Nw1Pjuv7sFRkCXX6gy+/2KNRI/55VDTZr/e6gQXAubp0Whuteh9+/lPfv0+X383RFDYxepNv5ji6rLjm416k/Xf5OX5fZep9fDeDrp2TIdPvU9UvLg4i822F495LkpOtdy/aPXmzf0G0e28ja24Pv2/1p3c7p9t27Wdicbtc/3JYRK/xSgexgvW8PGhXpdpeteMnmC4Lz16UkFgj199/S9eh1sPp6kGqA8e7pz0zSCFfLHC/aDqwH3P10Z55+2YHmeMpZfDHLbEbFlh3o9r6e7StDf9JaDxezQ97Xv3VxxH9rotAAk27Q5GDN8YfFV3XnvycFSze+dy89APvml7TjDIOfBprzA50tJ4tX6/y9SlFPQrT9qx4E3yv49qMNX5JMqhFKAQoLMOmZ3Zo9A1ksPpOp2IHmy0C81ewsOYHipRkIMmc7fytelkI1ityvQ0mCSyXSr34R9aYCL3b0brmnx3X9x576HUma2SzpvJelLcuxTCa4U0QBOG0qODMX/UDiFf2613a1oKnzyoWW+apZu7mUZ9F0Zuue7FyqYdtX19sLHPNpkAnW8m/r+HVdfl3GWNYM9LvMos3i4KL+6UetT9zQ7XFgGdYxkASv1ia0byffnB0Fa9lxk0v2OzYsOy+qmF1IgOn8oXpaxF2vtUy0QLKaNk9ag0xSSAtl5JwFu0g/1HMthTl7f7tDkc/KTcGY9Yjp2twND/x/NauJWfAfxra+r8uvy1jzlvuc54gBJt5wfPb1DsV3Czo5AkwcHWW/KrKnrKAmkufIBqwX5G7NNXd6EMfXHN8KRl2Yyoq0fvt32oJLk06vifNg9ApuX+eX7qixq+dk5Nu6aGkwbc0gkh6d0DFrICMRvy9H5lmW5Xd7yZgoqonkavOudfsB6za1BMAxfdTR13dM2+jdpWg7amtzI/ZlAliQ0TOrbdfWgBJIrjO5F/Su3zSuhaWNNUmas63qyW3M2z47b+jPc846DlzHQiT5LPfKxPR3neqnG3zUigowrjNhJD2UtIEc3cx9nXUcKa43SY/3sPUPWqblzWD8dHhBV1bDKjzAdOiatqbG7SxTaw2wl63pO3gwrJzVYHvg/1bzSoKC71iHUKYUt8zsz6CPSS3pTA2HoH2Sl2TRaRCJWzPq6tG6rC3Qxjf3Xzb2xVmHXJMpVlSAiRzTAhlPUfskV09LG0fmky/AlFzzcK1/JFPDVbhvhPs1i4ojAx11l7V1crjrkD3YfhX1eI2FoppI70hb9E66ouudfiAbIt26U2xqr9MZGa3QMc3v3SPkahNbt21HrprHovv3uwKc90CGJOvlcwW3PF2n2fxxrxtMl9E8WhJ39rC+/z52/Wi19+c+EMuYLum+HMpA4htZx8LYKyjAWJ+91zpWxAqCL2sEX5H2byxmKWW1fTmjZXUfLXiG0pZh2SjXwNmTpNMtEPqOxdWls0jasga7PutwgMlqB47CYBzJEGSjqx2DvZJrnXIGg6RrtiZjxzvCyOqBh0aE6QnG86W99jNVvXPdFBJg0kLauUjaDsK4ptMDPbBu2FXUInO64ePT2XVKLivSN0+zjvNL3edphO5CsrXH27IGP70M4Lz2qjSydnMyeMp2nsCxjHr3InXS69TaZXox7YXZW34ykM9VGDQ5eneaLu93v3q2k5+Vzk8HyLGtR6rax+fYsVCaBehABhcMMBBOLLjsBVvdxx8X9wnrzGBF5Mrj+59jVxthyVeHFziSN75yOCVtCnQHCdILuxtdfj4ZlBdJ/wL9nUGPeXSd2rOMtIv8/AXHaN5qdhHmUvrfuNNyI+l9gNb15agLHFx+J3FfV+K267Xc5nx5t/UoVdMiaVd16ZhlOK87ijpngJ4v7c35Jf3nKAXfK46Bo74GCvv8r0lu/ewjyXx2FXqpAaawXqRsQNcAG9MMXAw7goYFn7w1CBN1G5S3p8tgwl5shxlwO/ajjG09Gln2cqH9O8nn43ylwaDN5aMU4LPP2nHCsctfir7YsViFdlPrhl7NRo1Gff6IFcOulbHDZ12XtsPd6P+n4rCf4HLgd6y6d7yOojzLz2dv3acjuGQCaa93hN22X9bUaj3rZ9fIDS79rF1ZVuNmMGUjpg8qfBxMehFZcuDaWTjqMJt9wHax2JnsgymFBRl9Xc1SzTXpmG42A0vugz9tp/dafhJYVrLtMUhW1Srr5rT1Trbz3rqHMlVcXdNxH+NOXCeVoxSKm8u17MgRvCpTe/W6JyXL0lg/LZg2bONvhCM5Qw9H2sPVPCsmB+owC2mjXj4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzJL/AyjeGC7FidjSAAAAAElFTkSuQmCC" +} \ No newline at end of file diff --git a/src/appmixer/cloudflareWAF/waf/CreateCustomRules/create-custom-rules-handler.js b/src/appmixer/cloudflareWAF/waf/CreateCustomRules/create-custom-rules-handler.js new file mode 100644 index 000000000..b8d4c3e1d --- /dev/null +++ b/src/appmixer/cloudflareWAF/waf/CreateCustomRules/create-custom-rules-handler.js @@ -0,0 +1,35 @@ + +function output(context, message, output) { + return context.sendJson({ message }, output.valueOf()); +} + +function handleResponseError(context, err) { + const cloudflareAuthenticationError = 'Authentication error'; + const zoneIdNotFoundError = new RegExp( + 'Could not route to /client/v4/zones/.*/rulesets, perhaps your object identifier is invalid?' + ); + + if (context.httpRequest.isAxiosError(err) && err.response) { + const cloudflareResponse = err.response.data; + const errorMessage = cloudflareResponse.errors + ?.map(error => error.message).toString(); + const otherMessages = cloudflareResponse.messages + ?.map(msg => msg.message).toString(); + if (errorMessage === cloudflareAuthenticationError) { + return messages.AuthenticationError; + } else if (zoneIdNotFoundError.test(errorMessage)) { + return messages.ZoneIdNotFound; + } else if (errorMessage) return errorMessage; + else if (otherMessages) return otherMessages; + else return messages.cloudflareUnknownError(err); + } else if (err instanceof Error) { + return err.message; + } else { + return 'Got unknown error when trying to send request to cloudflare server.'; + } +} + +module.exports = { + output, + handleResponseError +}; diff --git a/src/appmixer/cloudflareWAF/waf/lib.js b/src/appmixer/cloudflareWAF/waf/lib.js new file mode 100644 index 000000000..f349928cf --- /dev/null +++ b/src/appmixer/cloudflareWAF/waf/lib.js @@ -0,0 +1,114 @@ +const ruleDescription = 'Generated Rule'; +const ruleRefPrefix = 'generated_rule'; +const RULE_MAX_CAPACITY = 4096; + +// extractIPs, getBlockExpression are also used in jobs.waf. They cannot be here, as jobs.waf +// cannot require waf/lib, therefore these methods are defined in the jobs.waf.js file +const { extractIPs, getBlockExpression } = require('../jobs.waf'); + +module.exports = { + getIpsFromRules, + prepareRulesForCreateOrUpdate, + getBlockRule, + findIpsInRules, + extractIPs +}; + +function getIpsFromRules(rules) { + + const ips = {}; + rules.forEach(rule => { + + const data = extractIPs(rule.expression); + data.forEach(ip => { + ips[ip] = { id: rule.id }; + }); + }); + + return ips; +} + +function findIpsInRules(rules, refIps = []) { + + const ips = {}; + rules.forEach(rule => { + + const ipsFromRule = extractIPs(rule.expression); + ipsFromRule.forEach(ip => { + if (refIps.includes(ip)) { + ips[ip] = { id: rule.id }; + } + }); + }); + + return ips; +} + +function prepareRulesForCreateOrUpdate(ips, rules, ruleCapacity) { + const ipsList = assignIpsToRules(ips, rules, ruleCapacity); + + const ipsGroupedByRules = Object.keys(ipsList).reduce((acc, ip) => { + const ruleId = ipsList[ip].id; + if (!acc[ruleId]) acc[ruleId] = []; + acc[ruleId].push(ip); + return acc; + }, {}); + + const res = []; + const groups = Object.entries(ipsGroupedByRules); + groups.forEach(entry => { + const ruleId = entry[0]; + const existingRule = rules.find(rule => rule.id === ruleId); + const expression = getBlockExpression(entry[1]); + + if (existingRule) { + if (existingRule.expression !== expression) { + res.push({ ...existingRule, expression }); + } + } else { + res.push(this.getBlockRule(groups.length, entry[1])); + } + }); + return res; +} + +function getBlockRule(index, ips) { + return { + action: 'block', + description: `${ruleDescription}#${index}`, + enabled: true, + expression: getBlockExpression(ips), + ref: `${ruleRefPrefix}#${index}` + }; +} + +function assignIpsToRules(ips, rules, ruleCapacity) { + const ipsList = getIpsFromRules(rules); + const rulesMetadata = rules.map(rule => { + return { + id: rule.id, + usedCapacity: rule.expression.length + }; + }); + + ips.forEach(ip => { + + const availableRule = getAvailableRule(rulesMetadata, ip.length + 1, ruleCapacity); + + if (availableRule) { + availableRule.usedCapacity += ip.length + 1; + ipsList[ip] = { id: availableRule.id }; + } else { + ipsList[ip] = { id: 'NULL', expression: '' }; + } + }); + + return ipsList; +} + +function getAvailableRule(rules, requiredCapacity, ruleCapacity = RULE_MAX_CAPACITY) { + + return rules.find(rule => rule.usedCapacity + requiredCapacity < ruleCapacity); +} + +