[NEW] Limit all DDP/Websocket requests (configurable via admin panel) (#13311)
parent
1c5ca3bbf7
commit
9d7d2705b8
@ -0,0 +1,187 @@ |
||||
import _ from 'underscore'; |
||||
import { Meteor } from 'meteor/meteor'; |
||||
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; |
||||
import { RateLimiter } from 'meteor/rate-limit'; |
||||
import { settings } from 'meteor/rocketchat:settings'; |
||||
import { metrics } from 'meteor/rocketchat:metrics'; |
||||
|
||||
// Get initial set of names already registered for rules
|
||||
const names = new Set(Object.values(DDPRateLimiter.printRules()) |
||||
.map((rule) => rule._matchers) |
||||
.filter((match) => typeof match.name === 'string') |
||||
.map((match) => match.name)); |
||||
|
||||
// Override the addRule to save new names added after this point
|
||||
const { addRule } = DDPRateLimiter; |
||||
DDPRateLimiter.addRule = (matcher, calls, time, callback) => { |
||||
if (matcher && typeof matcher.name === 'string') { |
||||
names.add(matcher.name); |
||||
} |
||||
return addRule.call(DDPRateLimiter, matcher, calls, time, callback); |
||||
}; |
||||
|
||||
// Need to override the meteor's code duo to a problem with the callback reply
|
||||
// being shared among all matchs
|
||||
RateLimiter.prototype.check = function(input) { |
||||
const self = this; |
||||
const reply = { |
||||
allowed: true, |
||||
timeToReset: 0, |
||||
numInvocationsLeft: Infinity, |
||||
}; |
||||
|
||||
const matchedRules = self._findAllMatchingRules(input); |
||||
_.each(matchedRules, function(rule) { |
||||
// ==== BEGIN OVERRIDE ====
|
||||
const callbackReply = { |
||||
allowed: true, |
||||
timeToReset: 0, |
||||
numInvocationsLeft: Infinity, |
||||
}; |
||||
// ==== END OVERRIDE ====
|
||||
|
||||
const ruleResult = rule.apply(input); |
||||
let numInvocations = rule.counters[ruleResult.key]; |
||||
|
||||
if (ruleResult.timeToNextReset < 0) { |
||||
// Reset all the counters since the rule has reset
|
||||
rule.resetCounter(); |
||||
ruleResult.timeSinceLastReset = new Date().getTime() - rule._lastResetTime; |
||||
ruleResult.timeToNextReset = rule.options.intervalTime; |
||||
numInvocations = 0; |
||||
} |
||||
|
||||
if (numInvocations > rule.options.numRequestsAllowed) { |
||||
// Only update timeToReset if the new time would be longer than the
|
||||
// previously set time. This is to ensure that if this input triggers
|
||||
// multiple rules, we return the longest period of time until they can
|
||||
// successfully make another call
|
||||
if (reply.timeToReset < ruleResult.timeToNextReset) { |
||||
reply.timeToReset = ruleResult.timeToNextReset; |
||||
} |
||||
reply.allowed = false; |
||||
reply.numInvocationsLeft = 0; |
||||
|
||||
// ==== BEGIN OVERRIDE ====
|
||||
callbackReply.timeToReset = ruleResult.timeToNextReset; |
||||
callbackReply.allowed = false; |
||||
callbackReply.numInvocationsLeft = 0; |
||||
rule._executeCallback(callbackReply, input); |
||||
// ==== END OVERRIDE ====
|
||||
} else { |
||||
// If this is an allowed attempt and we haven't failed on any of the
|
||||
// other rules that match, update the reply field.
|
||||
if (rule.options.numRequestsAllowed - numInvocations < reply.numInvocationsLeft && reply.allowed) { |
||||
reply.timeToReset = ruleResult.timeToNextReset; |
||||
reply.numInvocationsLeft = rule.options.numRequestsAllowed - numInvocations; |
||||
} |
||||
|
||||
// ==== BEGIN OVERRIDE ====
|
||||
callbackReply.timeToReset = ruleResult.timeToNextReset; |
||||
callbackReply.numInvocationsLeft = rule.options.numRequestsAllowed - numInvocations; |
||||
rule._executeCallback(callbackReply, input); |
||||
// ==== END OVERRIDE ====
|
||||
} |
||||
}); |
||||
return reply; |
||||
}; |
||||
|
||||
const checkNameNonStream = (name) => name && !names.has(name) && !name.startsWith('stream-'); |
||||
const checkNameForStream = (name) => name && !names.has(name) && name.startsWith('stream-'); |
||||
|
||||
const ruleIds = {}; |
||||
|
||||
const callback = (message, name) => (reply, input) => { |
||||
if (reply.allowed === false) { |
||||
console.warn('DDP RATE LIMIT:', message); |
||||
console.warn(JSON.stringify({ ...reply, ...input }, null, 2)); |
||||
metrics.ddpRateLimitExceeded.inc({ |
||||
limit_name: name, |
||||
user_id: input.userId, |
||||
client_address: input.clientAddress, |
||||
type: input.type, |
||||
name: input.name, |
||||
connection_id: input.connectionId, |
||||
}); |
||||
// } else {
|
||||
// console.log('DDP RATE LIMIT:', message);
|
||||
// console.log(JSON.stringify({ ...reply, ...input }, null, 2));
|
||||
} |
||||
}; |
||||
|
||||
const messages = { |
||||
IP: 'address', |
||||
User: 'userId', |
||||
Connection: 'connectionId', |
||||
User_By_Method: 'userId per method', |
||||
Connection_By_Method: 'connectionId per method', |
||||
}; |
||||
|
||||
const reconfigureLimit = Meteor.bindEnvironment((name, rules, factor = 1) => { |
||||
if (ruleIds[name + factor]) { |
||||
DDPRateLimiter.removeRule(ruleIds[name + factor]); |
||||
} |
||||
|
||||
if (!settings.get(`DDP_Rate_Limit_${ name }_Enabled`)) { |
||||
return; |
||||
} |
||||
|
||||
ruleIds[name + factor] = addRule( |
||||
rules, |
||||
settings.get(`DDP_Rate_Limit_${ name }_Requests_Allowed`) * factor, |
||||
settings.get(`DDP_Rate_Limit_${ name }_Interval_Time`) * factor, |
||||
callback(`limit by ${ messages[name] }`, name) |
||||
); |
||||
}); |
||||
|
||||
const configIP = _.debounce(() => { |
||||
reconfigureLimit('IP', { |
||||
clientAddress: (clientAddress) => clientAddress !== '127.0.0.1', |
||||
}); |
||||
}, 1000); |
||||
|
||||
const configUser = _.debounce(() => { |
||||
reconfigureLimit('User', { |
||||
userId: (userId) => userId != null, |
||||
}); |
||||
}, 1000); |
||||
|
||||
const configConnection = _.debounce(() => { |
||||
reconfigureLimit('Connection', { |
||||
connectionId: () => true, |
||||
}); |
||||
}, 1000); |
||||
|
||||
const configUserByMethod = _.debounce(() => { |
||||
reconfigureLimit('User_By_Method', { |
||||
type: () => true, |
||||
name: checkNameNonStream, |
||||
userId: (userId) => userId != null, |
||||
}); |
||||
reconfigureLimit('User_By_Method', { |
||||
type: () => true, |
||||
name: checkNameForStream, |
||||
userId: (userId) => userId != null, |
||||
}, 4); |
||||
}, 1000); |
||||
|
||||
const configConnectionByMethod = _.debounce(() => { |
||||
reconfigureLimit('Connection_By_Method', { |
||||
type: () => true, |
||||
name: checkNameNonStream, |
||||
connectionId: () => true, |
||||
}); |
||||
reconfigureLimit('Connection_By_Method', { |
||||
type: () => true, |
||||
name: checkNameForStream, |
||||
connectionId: () => true, |
||||
}, 4); |
||||
}, 1000); |
||||
|
||||
if (!process.env.TEST_MODE) { |
||||
settings.get(/^DDP_Rate_Limit_IP_.+/, configIP); |
||||
settings.get(/^DDP_Rate_Limit_User_[^B].+/, configUser); |
||||
settings.get(/^DDP_Rate_Limit_Connection_[^B].+/, configConnection); |
||||
settings.get(/^DDP_Rate_Limit_User_By_Method_.+/, configUserByMethod); |
||||
settings.get(/^DDP_Rate_Limit_Connection_By_Method_.+/, configConnectionByMethod); |
||||
} |
Loading…
Reference in new issue