diff --git a/docs/client/data.js b/docs/client/data.js index 76bc801c33..edd28c8b37 100644 --- a/docs/client/data.js +++ b/docs/client/data.js @@ -1,12 +1,12 @@ // This file is automatically generated by JSDoc; regenerate it with scripts/admin/jsdoc/jsdoc.sh DocsData = { "Accounts": { - "filepath": "accounts-base/accounts_common.js", + "filepath": "accounts-base/globals_server.js", "kind": "namespace", "lineno": 1, "longname": "Accounts", "name": "Accounts", - "summary": "The namespace for all accounts-related methods." + "summary": "The namespace for all server-side accounts-related methods." }, "Accounts.changePassword": { "filepath": "accounts-password/password_client.js", @@ -50,75 +50,6 @@ DocsData = { "scope": "static", "summary": "Change the current user's password. Must be logged in." }, - "Accounts.config": { - "filepath": "accounts-base/accounts_common.js", - "kind": "function", - "lineno": 55, - "locus": "Anywhere", - "longname": "Accounts.config", - "memberof": "Accounts", - "name": "config", - "options": [ - { - "description": "

New users with an email address will receive an address verification email.

", - "name": "sendVerificationEmail", - "type": { - "names": [ - "Boolean" - ] - } - }, - { - "description": "

Calls to createUser from the client will be rejected. In addition, if you are using accounts-ui, the "Create account" link will not be available.

", - "name": "forbidClientAccountCreation", - "type": { - "names": [ - "Boolean" - ] - } - }, - { - "description": "

If set to a string, only allows new users if the domain part of their email address matches the string. If set to a function, only allows new users if the function returns true. The function is passed the full email address of the proposed new user. Works with password-based sign-in and external services that expose email addresses (Google, Facebook, GitHub). All existing users still can log in after enabling this option. Example: Accounts.config({ restrictCreationByEmailDomain: 'school.edu' }).

", - "name": "restrictCreationByEmailDomain", - "type": { - "names": [ - "String", - "function" - ] - } - }, - { - "description": "

The number of days from when a user logs in until their token expires and they are logged out. Defaults to 90. Set to null to disable login expiration.

", - "name": "loginExpirationInDays", - "type": { - "names": [ - "Number" - ] - } - }, - { - "description": "

When using the oauth-encryption package, the 16 byte key using to encrypt sensitive account credentials in the database, encoded in base64. This option may only be specifed on the server. See packages/oauth-encryption/README.md for details.

", - "name": "oauthSecretKey", - "type": { - "names": [ - "String" - ] - } - } - ], - "params": [ - { - "name": "options", - "type": { - "names": [ - "Object" - ] - } - } - ], - "scope": "static", - "summary": "Set global accounts options." - }, "Accounts.createUser": { "filepath": "accounts-password/password_client.js", "kind": "function", @@ -241,144 +172,6 @@ DocsData = { "scope": "static", "summary": "Request a forgot password email." }, - "Accounts.onCreateUser": { - "filepath": "accounts-base/accounts_server.js", - "kind": "function", - "lineno": 961, - "locus": "Server", - "longname": "Accounts.onCreateUser", - "memberof": "Accounts", - "name": "onCreateUser", - "options": [], - "params": [ - { - "description": "

Called whenever a new user is created. Return the new user object, or throw an Error to abort the creation.

", - "name": "func", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Customize new user creation." - }, - "Accounts.onEmailVerificationLink": { - "filepath": "accounts-base/url_client.js", - "kind": "function", - "lineno": 110, - "locus": "Client", - "longname": "Accounts.onEmailVerificationLink", - "memberof": "Accounts", - "name": "onEmailVerificationLink", - "options": [], - "params": [ - { - "description": "

The function to call. It is given two arguments:

\n
    \n
  1. token: An email verification token that can be passed to\nAccounts.verifyEmail.
  2. \n
  3. done: A function to call when the email verification UI flow is complete.\nThe normal login process is suspended until this function is called, so\nthat the user can be notified that they are verifying their email before\nbeing logged in.
  4. \n
", - "name": "callback", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Register a function to call when an email verification link is\nclicked in an email sent by\n[`Accounts.sendVerificationEmail`](#accounts_sendverificationemail).\nThis function should be called in top-level code, not inside\n`Meteor.startup()`." - }, - "Accounts.onEnrollmentLink": { - "filepath": "accounts-base/url_client.js", - "kind": "function", - "lineno": 135, - "locus": "Client", - "longname": "Accounts.onEnrollmentLink", - "memberof": "Accounts", - "name": "onEnrollmentLink", - "options": [], - "params": [ - { - "description": "

The function to call. It is given two arguments:

\n
    \n
  1. token: A password reset token that can be passed to\nAccounts.resetPassword to give the newly\nenrolled account a password.
  2. \n
  3. done: A function to call when the enrollment UI flow is complete.\nThe normal login process is suspended until this function is called, so that\nuser A can be enrolled even if user B was logged in.
  4. \n
", - "name": "callback", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Register a function to call when an account enrollment link is\nclicked in an email sent by\n[`Accounts.sendEnrollmentEmail`](#accounts_sendenrollmentemail).\nThis function should be called in top-level code, not inside\n`Meteor.startup()`." - }, - "Accounts.onLogin": { - "filepath": "accounts-base/accounts_common.js", - "kind": "function", - "lineno": 202, - "locus": "Anywhere", - "longname": "Accounts.onLogin", - "memberof": "Accounts", - "name": "onLogin", - "options": [], - "params": [ - { - "description": "

The callback to be called when login is successful.

", - "name": "func", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Register a callback to be called after a login attempt succeeds." - }, - "Accounts.onLoginFailure": { - "filepath": "accounts-base/accounts_common.js", - "kind": "function", - "lineno": 211, - "locus": "Anywhere", - "longname": "Accounts.onLoginFailure", - "memberof": "Accounts", - "name": "onLoginFailure", - "options": [], - "params": [ - { - "description": "

The callback to be called after the login has failed.

", - "name": "func", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Register a callback to be called after a login attempt fails." - }, - "Accounts.onResetPasswordLink": { - "filepath": "accounts-base/url_client.js", - "kind": "function", - "lineno": 85, - "locus": "Client", - "longname": "Accounts.onResetPasswordLink", - "memberof": "Accounts", - "name": "onResetPasswordLink", - "options": [], - "params": [ - { - "description": "

The function to call. It is given two arguments:

\n
    \n
  1. token: A password reset token that can be passed to\nAccounts.resetPassword.
  2. \n
  3. done: A function to call when the password reset UI flow is complete. The normal\nlogin process is suspended until this function is called, so that the\npassword for user A can be reset even if user B was logged in.
  4. \n
", - "name": "callback", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Register a function to call when a reset password link is clicked\nin an email sent by\n[`Accounts.sendResetPasswordEmail`](#accounts_sendresetpasswordemail).\nThis function should be called in top-level code, not inside\n`Meteor.startup()`." - }, "Accounts.resetPassword": { "filepath": "accounts-password/password_client.js", "kind": "function", @@ -640,52 +433,6 @@ DocsData = { "scope": "static", "summary": "Configure the behavior of [`{{> loginButtons}}`](#accountsui)." }, - "Accounts.validateLoginAttempt": { - "filepath": "accounts-base/accounts_server.js", - "kind": "function", - "lineno": 43, - "locus": "Server", - "longname": "Accounts.validateLoginAttempt", - "memberof": "Accounts", - "name": "validateLoginAttempt", - "options": [], - "params": [ - { - "description": "

Called whenever a login is attempted (either successful or unsuccessful). A login can be aborted by returning a falsy value or throwing an exception.

", - "name": "func", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Validate login attempts." - }, - "Accounts.validateNewUser": { - "filepath": "accounts-base/accounts_server.js", - "kind": "function", - "lineno": 1041, - "locus": "Server", - "longname": "Accounts.validateNewUser", - "memberof": "Accounts", - "name": "validateNewUser", - "options": [], - "params": [ - { - "description": "

Called whenever a new user is created. Takes the new user object, and returns true to allow the creation or false to abort.

", - "name": "func", - "type": { - "names": [ - "function" - ] - } - } - ], - "scope": "static", - "summary": "Set restrictions on new user creation." - }, "Accounts.verifyEmail": { "filepath": "accounts-password/password_client.js", "kind": "function", @@ -719,6 +466,272 @@ DocsData = { "scope": "static", "summary": "Marks the user's email address as verified. Logs the user in afterwards." }, + "Ap.config": { + "filepath": "accounts-base/accounts_common.js", + "kind": "function", + "lineno": 110, + "locus": "Anywhere", + "longname": "Ap.config", + "memberof": "Ap", + "name": "config", + "options": [ + { + "description": "

New users with an email address will receive an address verification email.

", + "name": "sendVerificationEmail", + "type": { + "names": [ + "Boolean" + ] + } + }, + { + "description": "

Calls to createUser from the client will be rejected. In addition, if you are using accounts-ui, the "Create account" link will not be available.

", + "name": "forbidClientAccountCreation", + "type": { + "names": [ + "Boolean" + ] + } + }, + { + "description": "

If set to a string, only allows new users if the domain part of their email address matches the string. If set to a function, only allows new users if the function returns true. The function is passed the full email address of the proposed new user. Works with password-based sign-in and external services that expose email addresses (Google, Facebook, GitHub). All existing users still can log in after enabling this option. Example: Accounts.config({ restrictCreationByEmailDomain: 'school.edu' }).

", + "name": "restrictCreationByEmailDomain", + "type": { + "names": [ + "String", + "function" + ] + } + }, + { + "description": "

The number of days from when a user logs in until their token expires and they are logged out. Defaults to 90. Set to null to disable login expiration.

", + "name": "loginExpirationInDays", + "type": { + "names": [ + "Number" + ] + } + }, + { + "description": "

When using the oauth-encryption package, the 16 byte key using to encrypt sensitive account credentials in the database, encoded in base64. This option may only be specifed on the server. See packages/oauth-encryption/README.md for details.

", + "name": "oauthSecretKey", + "type": { + "names": [ + "String" + ] + } + } + ], + "params": [ + { + "name": "options", + "type": { + "names": [ + "Object" + ] + } + } + ], + "scope": "static", + "summary": "Set global accounts options." + }, + "Ap.onCreateUser": { + "filepath": "accounts-base/accounts_server.js", + "kind": "function", + "lineno": 1173, + "locus": "Server", + "longname": "Ap.onCreateUser", + "memberof": "Ap", + "name": "onCreateUser", + "options": [], + "params": [ + { + "description": "

Called whenever a new user is created. Return the new user object, or throw an Error to abort the creation.

", + "name": "func", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Customize new user creation." + }, + "Ap.onEmailVerificationLink": { + "filepath": "accounts-base/url_client.js", + "kind": "function", + "lineno": 129, + "locus": "Client", + "longname": "Ap.onEmailVerificationLink", + "memberof": "Ap", + "name": "onEmailVerificationLink", + "options": [], + "params": [ + { + "description": "

The function to call. It is given two arguments:

\n
    \n
  1. token: An email verification token that can be passed to\nAccounts.verifyEmail.
  2. \n
  3. done: A function to call when the email verification UI flow is complete.\nThe normal login process is suspended until this function is called, so\nthat the user can be notified that they are verifying their email before\nbeing logged in.
  4. \n
", + "name": "callback", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Register a function to call when an email verification link is\nclicked in an email sent by\n[`Accounts.sendVerificationEmail`](#accounts_sendverificationemail).\nThis function should be called in top-level code, not inside\n`Meteor.startup()`." + }, + "Ap.onEnrollmentLink": { + "filepath": "accounts-base/url_client.js", + "kind": "function", + "lineno": 154, + "locus": "Client", + "longname": "Ap.onEnrollmentLink", + "memberof": "Ap", + "name": "onEnrollmentLink", + "options": [], + "params": [ + { + "description": "

The function to call. It is given two arguments:

\n
    \n
  1. token: A password reset token that can be passed to\nAccounts.resetPassword to give the newly\nenrolled account a password.
  2. \n
  3. done: A function to call when the enrollment UI flow is complete.\nThe normal login process is suspended until this function is called, so that\nuser A can be enrolled even if user B was logged in.
  4. \n
", + "name": "callback", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Register a function to call when an account enrollment link is\nclicked in an email sent by\n[`Accounts.sendEnrollmentEmail`](#accounts_sendenrollmentemail).\nThis function should be called in top-level code, not inside\n`Meteor.startup()`." + }, + "Ap.onLogin": { + "filepath": "accounts-base/accounts_common.js", + "kind": "function", + "lineno": 239, + "locus": "Anywhere", + "longname": "Ap.onLogin", + "memberof": "Ap", + "name": "onLogin", + "options": [], + "params": [ + { + "description": "

The callback to be called when login is successful.

", + "name": "func", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Register a callback to be called after a login attempt succeeds." + }, + "Ap.onLoginFailure": { + "filepath": "accounts-base/accounts_common.js", + "kind": "function", + "lineno": 248, + "locus": "Anywhere", + "longname": "Ap.onLoginFailure", + "memberof": "Ap", + "name": "onLoginFailure", + "options": [], + "params": [ + { + "description": "

The callback to be called after the login has failed.

", + "name": "func", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Register a callback to be called after a login attempt fails." + }, + "Ap.onResetPasswordLink": { + "filepath": "accounts-base/url_client.js", + "kind": "function", + "lineno": 104, + "locus": "Client", + "longname": "Ap.onResetPasswordLink", + "memberof": "Ap", + "name": "onResetPasswordLink", + "options": [], + "params": [ + { + "description": "

The function to call. It is given two arguments:

\n
    \n
  1. token: A password reset token that can be passed to\nAccounts.resetPassword.
  2. \n
  3. done: A function to call when the password reset UI flow is complete. The normal\nlogin process is suspended until this function is called, so that the\npassword for user A can be reset even if user B was logged in.
  4. \n
", + "name": "callback", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Register a function to call when a reset password link is clicked\nin an email sent by\n[`Accounts.sendResetPasswordEmail`](#accounts_sendresetpasswordemail).\nThis function should be called in top-level code, not inside\n`Meteor.startup()`." + }, + "Ap.userId": { + "filepath": "accounts-base/accounts_common.js", + "kind": "function", + "lineno": 39, + "locus": "Anywhere but publish functions", + "longname": "Ap.userId", + "memberof": "Ap", + "name": "userId", + "options": [], + "params": [], + "scope": "static", + "summary": "Get the current user id, or `null` if no user is logged in. A reactive data source." + }, + "Ap.validateLoginAttempt": { + "filepath": "accounts-base/accounts_server.js", + "kind": "function", + "lineno": 90, + "locus": "Server", + "longname": "Ap.validateLoginAttempt", + "memberof": "Ap", + "name": "validateLoginAttempt", + "options": [], + "params": [ + { + "description": "

Called whenever a login is attempted (either successful or unsuccessful). A login can be aborted by returning a falsy value or throwing an exception.

", + "name": "func", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Validate login attempts." + }, + "Ap.validateNewUser": { + "filepath": "accounts-base/accounts_server.js", + "kind": "function", + "lineno": 1256, + "locus": "Server", + "longname": "Ap.validateNewUser", + "memberof": "Ap", + "name": "validateNewUser", + "options": [], + "params": [ + { + "description": "

Called whenever a new user is created. Takes the new user object, and returns true to allow the creation or false to abort.

", + "name": "func", + "type": { + "names": [ + "function" + ] + } + } + ], + "scope": "static", + "summary": "Set restrictions on new user creation." + }, "App": { "kind": "namespace", "longname": "App", @@ -972,7 +985,7 @@ DocsData = { "Blaze.Each": { "filepath": "blaze/builtins.js", "kind": "function", - "lineno": 123, + "lineno": 122, "locus": "Client", "longname": "Blaze.Each", "memberof": "Blaze", @@ -1014,7 +1027,7 @@ DocsData = { "Blaze.If": { "filepath": "blaze/builtins.js", "kind": "function", - "lineno": 74, + "lineno": 73, "locus": "Client", "longname": "Blaze.If", "memberof": "Blaze", @@ -1056,15 +1069,15 @@ DocsData = { "Blaze.Let": { "filepath": "blaze/builtins.js", "kind": "function", - "lineno": 60, + "lineno": 59, "longname": "Blaze.Let", "memberof": "Blaze", "name": "Let", "options": [], "params": [ { - "description": "

A function to reactively re-run. The returned\ndictionary maps names of bindings with values or computations to reactively\nre-run.

", - "name": "bindingsFunc", + "description": "

Dictionary mapping names of bindings to\nvalues or computations to reactively re-run.

", + "name": "bindings", "type": { "names": [ "function" @@ -1427,7 +1440,7 @@ DocsData = { "Blaze.Unless": { "filepath": "blaze/builtins.js", "kind": "function", - "lineno": 99, + "lineno": 98, "locus": "Client", "longname": "Blaze.Unless", "memberof": "Blaze", @@ -1535,7 +1548,7 @@ DocsData = { "Blaze.currentView": { "filepath": "blaze/view.js", "kind": "member", - "lineno": 536, + "lineno": 532, "locus": "Client", "longname": "Blaze.currentView", "memberof": "Blaze", @@ -1551,7 +1564,7 @@ DocsData = { "Blaze.getData": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 747, + "lineno": 743, "locus": "Client", "longname": "Blaze.getData", "memberof": "Blaze", @@ -1576,7 +1589,7 @@ DocsData = { "Blaze.getView": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 785, + "lineno": 781, "locus": "Client", "longname": "Blaze.getView", "memberof": "Blaze", @@ -1623,7 +1636,7 @@ DocsData = { "Blaze.remove": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 681, + "lineno": 677, "locus": "Client", "longname": "Blaze.remove", "memberof": "Blaze", @@ -1646,7 +1659,7 @@ DocsData = { "Blaze.render": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 618, + "lineno": 614, "locus": "Client", "longname": "Blaze.render", "memberof": "Blaze", @@ -1699,7 +1712,7 @@ DocsData = { "Blaze.renderWithData": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 669, + "lineno": 665, "locus": "Client", "longname": "Blaze.renderWithData", "memberof": "Blaze", @@ -1762,7 +1775,7 @@ DocsData = { "Blaze.toHTML": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 702, + "lineno": 698, "locus": "Client", "longname": "Blaze.toHTML", "memberof": "Blaze", @@ -1786,7 +1799,7 @@ DocsData = { "Blaze.toHTMLWithData": { "filepath": "blaze/view.js", "kind": "function", - "lineno": 714, + "lineno": 710, "locus": "Client", "longname": "Blaze.toHTMLWithData", "memberof": "Blaze", @@ -2238,7 +2251,7 @@ DocsData = { "DDP.connect": { "filepath": "ddp-client/livedata_connection.js", "kind": "function", - "lineno": 1624, + "lineno": 1626, "locus": "Anywhere", "longname": "DDP.connect", "memberof": "DDP", @@ -2363,6 +2376,101 @@ DocsData = { "scope": "instance", "summary": "The id of the user that made this method call, or `null` if no user was logged in." }, + "DDPRateLimiter.addRule": { + "filepath": "ddp-rate-limiter/ddp-rate-limiter.js", + "kind": "function", + "lineno": 77, + "longname": "DDPRateLimiter.addRule", + "memberof": "DDPRateLimiter", + "name": "addRule", + "options": [], + "params": [ + { + "description": "

Matchers specify which events are counted towards a rate limit. A matcher\n is an object that has a subset of the same properties as the event objects\n described above. Each value in a matcher object is one of the following:

\n\n

Here's how events are counted: Each event that satisfies the matcher's\nfilter is mapped to a bucket. Buckets are uniquely determined by the\nevent object's values for all properties present in both the matcher and\nevent objects.

", + "name": "matcher", + "type": { + "names": [ + "Object" + ] + } + }, + { + "description": "

number of requests allowed per time interval.\nDefault = 10.

", + "name": "numRequests", + "type": { + "names": [ + "number" + ] + } + }, + { + "description": "

time interval in milliseconds after which\nrule's counters are reset. Default = 1000.

", + "name": "timeInterval", + "type": { + "names": [ + "number" + ] + } + } + ], + "scope": "static", + "summary": "Add a rule that matches against a stream of events describing method or\nsubscription attempts. Each event is an object with the following properties:\n\n- `type`: Either \"method\" or \"subscription\"\n- `name`: The name of the method or subscription being called\n- `userId`: The user ID attempting the method or subscription\n- `connectionId`: A string representing the user's DDP connection\n- `ipAddr`: The IP address of the user\n\nReturns unique `ruleId` that can be passed to `removeRule`." + }, + "DDPRateLimiter.removeRule": { + "filepath": "ddp-rate-limiter/ddp-rate-limiter.js", + "kind": "function", + "lineno": 86, + "longname": "DDPRateLimiter.removeRule", + "memberof": "DDPRateLimiter", + "name": "removeRule", + "options": [], + "params": [ + { + "description": "

'ruleId' returned from addRule

", + "name": "id", + "type": { + "names": [ + "string" + ] + } + } + ], + "returns": [ + { + "description": "

True if a rule was removed.

", + "type": { + "names": [ + "boolean" + ] + } + } + ], + "scope": "static", + "summary": "Removes the rule with specified id." + }, + "DDPRateLimiter.setErrorMessage": { + "filepath": "ddp-rate-limiter/ddp-rate-limiter.js", + "kind": "function", + "lineno": 25, + "longname": "DDPRateLimiter.setErrorMessage", + "memberof": "DDPRateLimiter", + "name": "setErrorMessage", + "options": [], + "params": [ + { + "description": "

Function that takes an object with a\ntimeToReset field that specifies the first time a method or subscription\ncall is allowed.

", + "name": "message", + "type": { + "names": [ + "string", + "function" + ] + } + } + ], + "scope": "static", + "summary": "Set error message text when method or subscription rate limit\nexceeded." + }, "EJSON": { "filepath": "ejson/ejson.js", "kind": "namespace", @@ -2482,7 +2590,7 @@ DocsData = { "EJSON.clone": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 455, + "lineno": 462, "locus": "Anywhere", "longname": "EJSON.clone", "memberof": "EJSON", @@ -2505,7 +2613,7 @@ DocsData = { "EJSON.equals": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 369, + "lineno": 376, "locus": "Anywhere", "longname": "EJSON.equals", "memberof": "EJSON", @@ -2554,7 +2662,7 @@ DocsData = { "EJSON.fromJSONValue": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 310, + "lineno": 317, "locus": "Anywhere", "longname": "EJSON.fromJSONValue", "memberof": "EJSON", @@ -2577,7 +2685,7 @@ DocsData = { "EJSON.isBinary": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 356, + "lineno": 363, "locus": "Anywhere", "longname": "EJSON.isBinary", "memberof": "EJSON", @@ -2600,7 +2708,7 @@ DocsData = { "EJSON.newBinary": { "filepath": "ejson/ejson.js", "kind": "member", - "lineno": 509, + "lineno": 516, "locus": "Anywhere", "longname": "EJSON.newBinary", "memberof": "EJSON", @@ -2622,7 +2730,7 @@ DocsData = { "EJSON.parse": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 345, + "lineno": 352, "locus": "Anywhere", "longname": "EJSON.parse", "memberof": "EJSON", @@ -2645,7 +2753,7 @@ DocsData = { "EJSON.stringify": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 331, + "lineno": 338, "locus": "Anywhere", "longname": "EJSON.stringify", "memberof": "EJSON", @@ -2698,7 +2806,7 @@ DocsData = { "EJSON.toJSONValue": { "filepath": "ejson/ejson.js", "kind": "function", - "lineno": 241, + "lineno": 248, "locus": "Anywhere", "longname": "EJSON.toJSONValue", "memberof": "EJSON", @@ -3204,7 +3312,7 @@ DocsData = { "options": [], "params": [ { - "description": "

A string code uniquely identifying this kind of error.\nThis string should be used by callers of the method to determine the\nappropriate action to take, instead of attempting to parse the reason\nor details fields. For example:

\n
// on the server, pick a code unique to this error\n// the reason field should be a useful debug message\nthrow new Meteor.Error("logged-out", \n  "The user must be logged in to post a comment.");\n\n// on the client\nMeteor.call("methodName", function (error) {\n  // identify the error\n  if (error.error === "logged-out") {\n    // show a nice error message\n    Session.set("errorMessage", "Please log in to post a comment.");\n  }\n});

For legacy reasons, some built-in Meteor functions such as check throw\nerrors with a number in this field.

", + "description": "

A string code uniquely identifying this kind of error.\nThis string should be used by callers of the method to determine the\nappropriate action to take, instead of attempting to parse the reason\nor details fields. For example:

\n
// on the server, pick a code unique to this error\n// the reason field should be a useful debug message\nthrow new Meteor.Error("logged-out", \n  "The user must be logged in to post a comment.");\n\n// on the client\nMeteor.call("methodName", function (error) {\n  // identify the error\n  if (error && error.error === "logged-out") {\n    // show a nice error message\n    Session.set("errorMessage", "Please log in to post a comment.");\n  }\n});

For legacy reasons, some built-in Meteor functions such as check throw\nerrors with a number in this field.

", "name": "error", "type": { "names": [ @@ -3300,7 +3408,7 @@ DocsData = { "Meteor.apply": { "filepath": "ddp-client/livedata_connection.js", "kind": "function", - "lineno": 703, + "lineno": 705, "locus": "Anywhere", "longname": "Meteor.apply", "memberof": "Meteor", @@ -3370,7 +3478,7 @@ DocsData = { "Meteor.call": { "filepath": "ddp-client/livedata_connection.js", "kind": "function", - "lineno": 662, + "lineno": 664, "locus": "Anywhere", "longname": "Meteor.call", "memberof": "Meteor", @@ -3459,7 +3567,7 @@ DocsData = { "Meteor.disconnect": { "filepath": "ddp-client/livedata_connection.js", "kind": "function", - "lineno": 1015, + "lineno": 1017, "locus": "Client", "longname": "Meteor.disconnect", "memberof": "Meteor", @@ -3520,7 +3628,7 @@ DocsData = { "Meteor.loggingIn": { "filepath": "accounts-base/accounts_client.js", "kind": "function", - "lineno": 31, + "lineno": 54, "locus": "Client", "longname": "Meteor.loggingIn", "memberof": "Meteor", @@ -3566,6 +3674,15 @@ DocsData = { ] } }, + { + "description": "

String of the kind of prompt(s) to always show. Valid options are "consent", "none", "select_account" or a combination. i.e. "select_account+consent". Currently only supported with Google.

", + "name": "prompt", + "type": { + "names": [ + "String" + ] + } + }, { "description": "

An email address that the external service will use to pre-fill the login prompt. Currently only supported with Meteor developer accounts.

", "name": "userEmail", @@ -3655,7 +3772,7 @@ DocsData = { "Meteor.logout": { "filepath": "accounts-base/accounts_client.js", "kind": "function", - "lineno": 237, + "lineno": 255, "locus": "Client", "longname": "Meteor.logout", "memberof": "Meteor", @@ -3679,7 +3796,7 @@ DocsData = { "Meteor.logoutOtherClients": { "filepath": "accounts-base/accounts_client.js", "kind": "function", - "lineno": 253, + "lineno": 278, "locus": "Client", "longname": "Meteor.logoutOtherClients", "memberof": "Meteor", @@ -3703,7 +3820,7 @@ DocsData = { "Meteor.methods": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1457, + "lineno": 1504, "locus": "Anywhere", "longname": "Meteor.methods", "memberof": "Meteor", @@ -3726,7 +3843,7 @@ DocsData = { "Meteor.onConnection": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1318, + "lineno": 1365, "locus": "Server", "longname": "Meteor.onConnection", "memberof": "Meteor", @@ -3749,7 +3866,7 @@ DocsData = { "Meteor.publish": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1392, + "lineno": 1439, "locus": "Server", "longname": "Meteor.publish", "memberof": "Meteor", @@ -3781,7 +3898,7 @@ DocsData = { "Meteor.reconnect": { "filepath": "ddp-client/livedata_connection.js", "kind": "function", - "lineno": 1005, + "lineno": 1007, "locus": "Client", "longname": "Meteor.reconnect", "memberof": "Meteor", @@ -3913,7 +4030,7 @@ DocsData = { "Meteor.status": { "filepath": "ddp-client/livedata_connection.js", "kind": "function", - "lineno": 993, + "lineno": 995, "locus": "Client", "longname": "Meteor.status", "memberof": "Meteor", @@ -3968,9 +4085,9 @@ DocsData = { "summary": "Subscribe to a record set. Returns a handle that provides\n`stop()` and `ready()` methods." }, "Meteor.user": { - "filepath": "accounts-base/accounts_client.js", + "filepath": "accounts-base/accounts_common.js", "kind": "function", - "lineno": 42, + "lineno": 59, "locus": "Anywhere but publish functions", "longname": "Meteor.user", "memberof": "Meteor", @@ -3980,23 +4097,10 @@ DocsData = { "scope": "static", "summary": "Get the current user record, or `null` if no user is logged in. A reactive data source." }, - "Meteor.userId": { - "filepath": "accounts-base/accounts_client.js", - "kind": "function", - "lineno": 11, - "locus": "Anywhere but publish functions", - "longname": "Meteor.userId", - "memberof": "Meteor", - "name": "userId", - "options": [], - "params": [], - "scope": "static", - "summary": "Get the current user id, or `null` if no user is logged in. A reactive data source." - }, "Meteor.users": { - "filepath": "accounts-base/accounts_common.js", + "filepath": "accounts-base/globals_server.js", "kind": "member", - "lineno": 141, + "lineno": 16, "locus": "Anywhere", "longname": "Meteor.users", "memberof": "Meteor", @@ -4115,7 +4219,7 @@ DocsData = { "Mongo.Collection#allow": { "filepath": "mongo/collection.js", "kind": "function", - "lineno": 768, + "lineno": 776, "locus": "Server", "longname": "Mongo.Collection#allow", "memberof": "Mongo.Collection", @@ -4165,7 +4269,7 @@ DocsData = { "Mongo.Collection#deny": { "filepath": "mongo/collection.js", "kind": "function", - "lineno": 780, + "lineno": 788, "locus": "Server", "longname": "Mongo.Collection#deny", "memberof": "Mongo.Collection", @@ -4430,6 +4534,32 @@ DocsData = { "scope": "instance", "summary": "Insert a document in the collection. Returns its unique _id." }, + "Mongo.Collection#rawCollection": { + "filepath": "mongo/collection.js", + "kind": "function", + "lineno": 645, + "locus": "Server", + "longname": "Mongo.Collection#rawCollection", + "memberof": "Mongo.Collection", + "name": "rawCollection", + "options": [], + "params": [], + "scope": "instance", + "summary": "Returns the [`Collection`](http://mongodb.github.io/node-mongodb-native/1.4/api-generated/collection.html) object corresponding to this collection from the [npm `mongodb` driver module](https://www.npmjs.com/package/mongodb) which is wrapped by `Mongo.Collection`." + }, + "Mongo.Collection#rawDatabase": { + "filepath": "mongo/collection.js", + "kind": "function", + "lineno": 657, + "locus": "Server", + "longname": "Mongo.Collection#rawDatabase", + "memberof": "Mongo.Collection", + "name": "rawDatabase", + "options": [], + "params": [], + "scope": "instance", + "summary": "Returns the [`Db`](http://mongodb.github.io/node-mongodb-native/1.4/api-generated/db.html) object corresponding to this collection's database connection from the [npm `mongodb` driver module](https://www.npmjs.com/package/mongodb) which is wrapped by `Mongo.Collection`." + }, "Mongo.Collection#remove": { "filepath": "mongo/collection.js", "kind": "function", @@ -4598,7 +4728,7 @@ DocsData = { "filepath": "mongo/collection.js", "instancename": "cursor", "kind": "class", - "lineno": 671, + "lineno": 679, "longname": "Mongo.Cursor", "memberof": "Mongo", "name": "Cursor", @@ -4766,7 +4896,7 @@ DocsData = { "Mongo.ObjectID": { "filepath": "mongo/collection.js", "kind": "class", - "lineno": 664, + "lineno": 672, "locus": "Anywhere", "longname": "Mongo.ObjectID", "memberof": "Mongo", @@ -5047,6 +5177,16 @@ DocsData = { "String" ] } + }, + { + "description": "

Options that will be passed to build\nplugins. For example, for JavaScript files, you can pass {bare: true}\nto not wrap the individual file in its own closure.

", + "name": "fileOptions", + "optional": true, + "type": { + "names": [ + "Object" + ] + } } ], "scope": "instance", @@ -5421,7 +5561,7 @@ DocsData = { "filepath": "ddp-server/livedata_server.js", "instancename": "this", "kind": "class", - "lineno": 860, + "lineno": 907, "longname": "Subscription", "name": "Subscription", "options": [], @@ -5431,7 +5571,7 @@ DocsData = { "Subscription#added": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1148, + "lineno": 1195, "locus": "Server", "longname": "Subscription#added", "memberof": "Subscription", @@ -5472,7 +5612,7 @@ DocsData = { "Subscription#changed": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1166, + "lineno": 1213, "locus": "Server", "longname": "Subscription#changed", "memberof": "Subscription", @@ -5513,7 +5653,7 @@ DocsData = { "Subscription#connection": { "filepath": "ddp-server/livedata_server.js", "kind": "member", - "lineno": 870, + "lineno": 917, "locus": "Server", "longname": "Subscription#connection", "memberof": "Subscription", @@ -5524,7 +5664,7 @@ DocsData = { "Subscription#error": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1091, + "lineno": 1138, "locus": "Server", "longname": "Subscription#error", "memberof": "Subscription", @@ -5547,7 +5687,7 @@ DocsData = { "Subscription#onStop": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1123, + "lineno": 1170, "locus": "Server", "longname": "Subscription#onStop", "memberof": "Subscription", @@ -5570,7 +5710,7 @@ DocsData = { "Subscription#ready": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1199, + "lineno": 1246, "locus": "Server", "longname": "Subscription#ready", "memberof": "Subscription", @@ -5583,7 +5723,7 @@ DocsData = { "Subscription#removed": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1182, + "lineno": 1229, "locus": "Server", "longname": "Subscription#removed", "memberof": "Subscription", @@ -5615,7 +5755,7 @@ DocsData = { "Subscription#stop": { "filepath": "ddp-server/livedata_server.js", "kind": "function", - "lineno": 1109, + "lineno": 1156, "locus": "Server", "longname": "Subscription#stop", "memberof": "Subscription", @@ -5628,7 +5768,7 @@ DocsData = { "Subscription#userId": { "filepath": "ddp-server/livedata_server.js", "kind": "member", - "lineno": 912, + "lineno": 959, "locus": "Server", "longname": "Subscription#userId", "memberof": "Subscription", @@ -6372,7 +6512,7 @@ DocsData = { "filepath": "accounts-base/accounts_client.js", "ishelper": "true", "kind": "member", - "lineno": 353, + "lineno": 389, "longname": "currentUser", "name": "currentUser", "scope": "global", @@ -6382,7 +6522,7 @@ DocsData = { "filepath": "accounts-base/accounts_client.js", "ishelper": "true", "kind": "member", - "lineno": 363, + "lineno": 399, "longname": "loggingIn", "name": "loggingIn", "scope": "global", diff --git a/docs/client/full-api/api/accounts.md b/docs/client/full-api/api/accounts.md index f5d3db3f44..aacc82e177 100644 --- a/docs/client/full-api/api/accounts.md +++ b/docs/client/full-api/api/accounts.md @@ -398,4 +398,17 @@ These functions return an object with a single method, `stop`. Calling On the server, the callbacks get a single argument, the same attempt info object as [`validateLoginAttempt`](#accounts_validateloginattempt). On the client, no arguments are passed. + +

Rate Limiting

+ +By default, there are rules added to the [`DDPRateLimiter`](#ddpratelimiter) +that rate limit logins, new user registration and password reset calls to a +limit of 5 requests per 10 seconds per session. These are a basic solution +to dictionary attacks where a malicious user attempts to guess the passwords +of legitimate users by attempting all possible passwords. + +These rate limiting rules can be removed by calling +`Accounts.removeDefaultRateLimit()`. Please see the +[`DDPRateLimiter`](#ddpratelimiter) docs for more information. + {{/template}} diff --git a/docs/client/full-api/api/methods.md b/docs/client/full-api/api/methods.md index e912a6cfb3..ee127524af 100644 --- a/docs/client/full-api/api/methods.md +++ b/docs/client/full-api/api/methods.md @@ -180,4 +180,41 @@ even if the method's writes are not available yet, you can specify an passed as an array rather than directly as arguments, and you can specify options about how the client executes the method. +

DDPRateLimiter

+ +Customize rate limiting for methods and subscriptions. + +By default, `DDPRateLimiter` is configured with a single rule. This rule +limits login attempts, new user creation, and password resets to 5 attempts +every 10 seconds per connection. It can be removed by calling +`Accounts.removeDefaultRateLimit()`. + +{{> autoApiBox "DDPRateLimiter.addRule"}} + +Custom rules can be added by calling `DDPRateLimiter.addRule`. The rate +limiter is called on every method and subscription invocation. + +A rate limit is reached when a bucket has surpassed the rule's predefined +capactiy, at which point errors will be returned for that input until the +buckets are reset. Buckets are regularly reset after the end of a time +interval. + + +Here's example of defining a rule and adding it into the `DDPRateLimiter`: +```javascript +// Define a rule that matches login attempts by non-admin users +var loginRule = { + userId: function (userId) { + return Meteor.users.findOne(userId).type !== 'Admin'; + }, + type: 'method', + method: 'login' +} +// Add the rule, allowing up to 5 messages every 1000 milliseconds. +DDPRateLimiter.addRule(loginRule, 5, 1000); +``` +{{> autoApiBox "DDPRateLimiter.removeRule"}} +{{> autoApiBox "DDPRateLimiter.setErrorMessage"}} {{/template}} + +{{> auto}} diff --git a/docs/client/full-api/tableOfContents.js b/docs/client/full-api/tableOfContents.js index 8a4152a264..60f4b7a025 100644 --- a/docs/client/full-api/tableOfContents.js +++ b/docs/client/full-api/tableOfContents.js @@ -40,7 +40,8 @@ var toc = [ {instance: "this", name: "stop", id: "publish_stop"}, {instance: "this", name: "connection", id: "publish_connection"} ], - "Meteor.subscribe" + "Meteor.subscribe", + {name: "DDPRateLimiter", id: "ddpratelimiter"} ], {name: "Methods", id: "methods_header"}, [ @@ -53,7 +54,8 @@ var toc = [ ], "Meteor.Error", "Meteor.call", - "Meteor.apply" + "Meteor.apply", + {name: "DDPRateLimiter", id: "ddpratelimiter"} ], {name: "Check", id: "check_package"}, [ diff --git a/docs/client/names.json b/docs/client/names.json index 15cb12f716..937e5b1eb0 100644 --- a/docs/client/names.json +++ b/docs/client/names.json @@ -1,16 +1,10 @@ [ + "Accounts", "Accounts", "Accounts.changePassword", - "Accounts.config", "Accounts.createUser", "Accounts.emailTemplates", "Accounts.forgotPassword", - "Accounts.onCreateUser", - "Accounts.onEmailVerificationLink", - "Accounts.onEnrollmentLink", - "Accounts.onLogin", - "Accounts.onLoginFailure", - "Accounts.onResetPasswordLink", "Accounts.resetPassword", "Accounts.sendEnrollmentEmail", "Accounts.sendResetPasswordEmail", @@ -18,9 +12,17 @@ "Accounts.setPassword", "Accounts.ui", "Accounts.ui.config", - "Accounts.validateLoginAttempt", - "Accounts.validateNewUser", "Accounts.verifyEmail", + "Ap.config", + "Ap.onCreateUser", + "Ap.onEmailVerificationLink", + "Ap.onEnrollmentLink", + "Ap.onLogin", + "Ap.onLoginFailure", + "Ap.onResetPasswordLink", + "Ap.userId", + "Ap.validateLoginAttempt", + "Ap.validateNewUser", "App", "App.accessRule", "App.configurePlugin", @@ -86,6 +88,9 @@ "DDPCommon.MethodInvocation#setUserId", "DDPCommon.MethodInvocation#unblock", "DDPCommon.MethodInvocation#userId", + "DDPRateLimiter.addRule", + "DDPRateLimiter.removeRule", + "DDPRateLimiter.setErrorMessage", "EJSON", "EJSON.CustomType", "EJSON.CustomType#clone", @@ -138,7 +143,7 @@ "Meteor.status", "Meteor.subscribe", "Meteor.user", - "Meteor.userId", + "Meteor.users", "Meteor.users", "Meteor.wrapAsync", "Mongo", @@ -148,6 +153,8 @@ "Mongo.Collection#find", "Mongo.Collection#findOne", "Mongo.Collection#insert", + "Mongo.Collection#rawCollection", + "Mongo.Collection#rawDatabase", "Mongo.Collection#remove", "Mongo.Collection#update", "Mongo.Collection#upsert", diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index ef40a53b88..f16b1bc3f1 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -5,7 +5,7 @@ // @param options {Object} an object with fields: // - connection {Object} Optional DDP connection to reuse. // - ddpUrl {String} Optional URL for creating a new DDP connection. -AccountsClient = function AccountsClient(options) { +AccountsClient = function _AccountsClient(options) { AccountsCommon.call(this, options); this._loggingIn = false; diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index 8e999737ef..ead8a7fe71 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -3,7 +3,7 @@ // @param options {Object} an object with fields: // - connection {Object} Optional DDP connection to reuse. // - ddpUrl {String} Optional URL for creating a new DDP connection. -AccountsCommon = function AccountsCommon(options) { +AccountsCommon = function _AccountsCommon(options) { // Currently this is read directly by packages like accounts-password // and accounts-ui-unstyled. this._options = {}; diff --git a/packages/accounts-base/accounts_rate_limit.js b/packages/accounts-base/accounts_rate_limit.js new file mode 100644 index 0000000000..61c71da14f --- /dev/null +++ b/packages/accounts-base/accounts_rate_limit.js @@ -0,0 +1,21 @@ +var Ap = AccountsCommon.prototype; + +// Add a default rule of limiting logins, creating new users and password reset +// to 5 times every 10 seconds per connection. +var defaultRateLimiterRuleId = DDPRateLimiter.addRule({ + userId: null, + clientAddress: null, + type: 'method', + name: function (name) { + return _.contains(['login', 'createUser', 'resetPassword', + 'forgotPassword'], name); + }, + connectionId: function (connectionId) { + return true; + } +}, 5, 10000); + +// Removes default rate limiting rule +Ap.removeDefaultRateLimit = function () { + return DDPRateLimiter.removeRule(defaultRateLimiterRuleId); +} \ No newline at end of file diff --git a/packages/accounts-base/package.js b/packages/accounts-base/package.js index 436ac64634..24a08a24c6 100644 --- a/packages/accounts-base/package.js +++ b/packages/accounts-base/package.js @@ -5,6 +5,7 @@ Package.describe({ Package.onUse(function (api) { api.use('underscore', ['client', 'server']); + api.use('ddp-rate-limiter'); api.use('localstorage', 'client'); api.use('tracker', 'client'); api.use('check', 'server'); @@ -40,6 +41,7 @@ Package.onUse(function (api) { api.addFiles('accounts_common.js', ['client', 'server']); api.addFiles('accounts_server.js', 'server'); + api.addFiles('accounts_rate_limit.js'); api.addFiles('url_server.js', 'server'); // accounts_client must be before localstorage_token, because diff --git a/packages/ddp-rate-limiter/README.md b/packages/ddp-rate-limiter/README.md new file mode 100644 index 0000000000..22254d3bcf --- /dev/null +++ b/packages/ddp-rate-limiter/README.md @@ -0,0 +1,59 @@ +DDP Rate Limiter package +=== + +A rate limiter added directly to DDP that provides an API to add rules to +Meteor methods and collections. + +### Pre-defined Defaults + +If the `accounts-base` package is added to your +project, there are default rules added to limit logins, new user registration +and password resets calls to a limit of 5 requests per 10 seconds per +connection. +These provide a basic solution to dictionary attacks where a malicious user +attempts to guess the passwords of legitimate users by attempting all possible +passwords. To remove the default rule, a user can add +`Accounts.removeDefaultRateLimit()` to any server side code and the default +rate limit will be removed. + +### Configuration + +The `DDPRateLimiter` is configured with a set of rules. Each rule is a set of +keys to be inspected with filters on those keys to specify all DDP messages +that satisfy the rule. Each of these possible messages that satisfy the rule +is given a bucket by creating a unique string composed of all the keys in the +rule and the values from the message. After each rule's specified time +interval, all the buckets are deleted. A rate limit is said to have been hit +when a bucket has reached the rule's capacity, at which point errors will be +returned for that input until the buckets are reset. + +A rule is defined as a set of key-value pairs where the keys are one or more +of `userId`, `clientAddress`, `type`, `name`, and `connectionId`. The values +can either be null, primitives or functions. When you want to rate limit some +users but not others, a rule can match invocations using a function in a way +that is determined at run time based on the database or other data. In our +example, we check the database to avoid rate limiting admin users. + +When we add the rule to DDPRateLimiter, we also specify the number of messages +that we allow and the time interval on which the rate limit is reset. + +### Example Usage + +For example, let's add a rule for all login methods that restrict all users +but admins to 5 login attempts per second: + +```javascript +// Define a rule that matches login attempts by non-admin users +var loginRule = { + userId: function (userId) { + return Meteor.users.findOne(userId).type !== 'Admin'; + }, + type: 'method', + method: 'login' +} +// Add the rule, allowing up to 5 messages every 1000 milliseconds. +DDPRateLimiter.addRule(loginRule, 5, 1000); +``` + +For more information, check out the documentation on the [DDP Rate Limiter] +(http://docs.meteor.com/#ddpratelimiter). \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js b/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js new file mode 100644 index 0000000000..55a18d9daa --- /dev/null +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-test-service.js @@ -0,0 +1,66 @@ +Meteor.methods({ + // Adds in a new rule with the specific intervalTime and connectionId as + // passed in to speed up testing & allow the rule to apply to the connection + // testing the rate limit. + addRuleToDDPRateLimiter: function () { + var connection = this.connection; + connection.lastRateLimitEvent = connection.lastRateLimitEvent || {}; + connection.lastMethodName = connection.lastMethodName || ''; + // XXX In Javascript v8 engine, we are currently guaranteed the ordering of + // the keys in objects as they are listed. This may change in future + // iterations of v8 for performance reasons and will potentially break this + // test. + this.ruleId = DDPRateLimiter.addRule({ + name: function (name) { + connection.lastMethodName = name; + if (name !== 'getLastRateLimitEvent') { + connection.lastRateLimitEvent.name = name; + } + return name !== "a-method-that-is-not-rate-limited"; + }, + userId: function (userId) { + connection.lastRateLimitEvent.userId = userId; + return true; + }, + type: function (type) { + // Special check to return proper name since 'getLastRateLimitEvent' + // is another method call + if (connection.lastMethodName !== 'getLastRateLimitEvent'){ + connection.lastRateLimitEvent.type = type; + } + return true; + }, + clientAddress: function (clientAddress) { + connection.lastRateLimitEvent.clientAddress = clientAddress + return true; + }, + connectionId: this.connection.id + }, RATE_LIMIT_NUM_CALLS, RATE_LIMIT_INTERVAL_TIME_MS); + + return this.ruleId; + }, + getLastRateLimitEvent: function () { + return this.connection.lastRateLimitEvent; + }, + // Server side method to remove rule from DDP Rate Limiter + removeRuleFromDDPRateLimiter: function (id) { + return DDPRateLimiter.removeRule(id); + }, + // Print all the server rules for debugging purposes. + printCurrentListOfRules: function () { + console.log('Current list of rules :', DDPRateLimiter.printRules()); + }, + removeUserByUsername: function (username) { + Meteor.users.remove({username: username}); + }, + dummyMethod: function () { + return "yup"; + }, + 'a-method-that-is-not-rate-limited': function () { + return "not-rate-limited"; + } +}); + +Meteor.publish("testSubscription", function () { + return []; +}); diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests-common.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests-common.js new file mode 100644 index 0000000000..72ca8d2bfc --- /dev/null +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests-common.js @@ -0,0 +1,3 @@ +// Common settings for DDPRateLimiter tests. +RATE_LIMIT_NUM_CALLS = 5; +RATE_LIMIT_INTERVAL_TIME_MS = 5000; \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js new file mode 100644 index 0000000000..236960d93e --- /dev/null +++ b/packages/ddp-rate-limiter/ddp-rate-limiter-tests.js @@ -0,0 +1,335 @@ +// Test that we do hit the default login rate limit. +testAsyncMulti("ddp rate limiter - default rate limit", [ + function (test, expect) { + _.extend(this, createTestUser(test, expect)); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }, + function (test, expect) { + var self = this; + + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.loginWithPassword.bind(Meteor, self.username, 'fakePassword'), + { + expectedError: 403, + expectedResult: undefined, + expectedRateLimitWillBeHit: true, + expectedIntervalTimeInMs: 10000 + } + ); + }, + function (test, expect) { + Meteor.call("removeUserByUsername", this.username, expect(function () {})); + } +]); + +testAsyncMulti("ddp rate limiter - matchers get passed correct arguments", [ + function (test, expect) { + _.extend(this, createTestUser(test, expect)); + }, + function (test, expect) { + var self = this; + Meteor.call("addRuleToDDPRateLimiter", expect(function(error, result) { + self.ruleId = result; + })); + }, + function (test, expect) { + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.call.bind(Meteor, 'dummyMethod'), + { + expectedError: undefined, + expectedResult: "yup", + expectedRateLimitWillBeHit: true + } + ); + }, + function (test, expect) { + var self = this; + Meteor.call( + "getLastRateLimitEvent", expect(function (error, result) { + test.equal(error, undefined); + test.equal(result.userId, Meteor.userId()); + test.equal(result.type, "method"); + test.equal(result.name, "dummyMethod"); + test.isNotUndefined(result.clientAddress, "clientAddress is not defined"); + })); + }, + function (test, expect) { + Meteor.call("removeUserByUsername", this.username, expect(function () {})); + }, + function (test, expect) { + var self = this; + // Cleanup + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { + test.equal(result,true); + })); + } +]); + +testAsyncMulti("ddp rate limiter - we can return with type 'subscription'", [ + function (test, expect) { + var self = this; + Meteor.call("addRuleToDDPRateLimiter", expect( + function(error, result) { + self.ruleId = result; + })); + }, + function (test, expect) { + Meteor.subscribe('testSubscription'); + Meteor.call('getLastRateLimitEvent', expect(function(error, result){ + test.equal(error, undefined); + test.equal(result.type, "subscription"); + test.equal(result.name, "testSubscription"); + test.isNotUndefined(result.clientAddress, "clientAddress is not defined"); + })); + }, + function (test, expect) { + var self = this; + // Cleanup + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { + test.equal(result, true); + })); + } +]); + +testAsyncMulti("ddp rate limiter - rate limits to subscriptions", [ + function (test, expect) { + var self = this; + Meteor.call("addRuleToDDPRateLimiter", expect( + function(error, result) { + self.ruleId = result; + }) + ); + }, + function (test, expect) { + this.doSub = function (cb) { + Meteor.subscribe('testSubscription', { + onReady: function () { + cb(null, true); + }, + onStop: function (error) { + cb(error, undefined); + } + }); + }; + + callFnMultipleTimesThenExpectResult(test, expect, this.doSub, + { + expectedError: null, + expectedResult: true, + expectedRateLimitWillBeHit: true + } + ); + }, + function (test, expect) { + // After removing rule, subscriptions are no longer rate limited. + var self = this; + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { + test.equal(result,true); + })); + }, + function (test, expect) { + callFnMultipleTimesThenExpectResult(test, expect, this.doSub, + { + expectedError: null, + expectedResult: true, + expectedIntervalTimeInMs: false + }); + + callFnMultipleTimesThenExpectResult(test, expect, this.doSub, + { + expectedError: null, + expectedResult: true, + expectedIntervalTimeInMs: false + }); + } +]); + + +// - If you wait 5 seconds you are no longer rate limited +testAsyncMulti("ddp rate limiter - rate limit resets after " + + "RATE_LIMIT_INTERVAL_TIME_MS", [ + function (test, expect) { + _.extend(this, createTestUser(test, expect)); + }, + function (test, expect) { + var self = this; + Meteor.call("addRuleToDDPRateLimiter", expect(function(error, result) { + self.ruleId = result; + })); + }, + + function (test, expect) { + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.call.bind(Meteor, 'dummyMethod'), + { + expectedError: undefined, + expectedResult: "yup", + expectedRateLimitWillBeHit: true + } + ); + }, + function (test, expect) { + Meteor.setTimeout(expect(), RATE_LIMIT_INTERVAL_TIME_MS); + }, + function (test, expect) { + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.call.bind(Meteor, 'dummyMethod'), + { + expectedError: undefined, + expectedResult: "yup", + expectedRateLimitWillBeHit: true + } + ); + }, + function (test, expect) { + var self = this; + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { + test.equal(result, true); + })); + } +]); + +testAsyncMulti("ddp rate limiter - 'a-method-that-is-not-rate-limited' is not" + + " rate limited", [ + function (test, expect) { + var self = this; + Meteor.call('addRuleToDDPRateLimiter', expect(function(error, result){ + self.ruleId = result; + })); + }, + function (test, expect) { + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.call.bind(Meteor, 'a-method-that-is-not-rate-limited'), + { + expectedError: undefined, + expectedResult: "not-rate-limited", + expectedRateLimitWillBeHit: false + }); + }, + function (test, expect) { + var self = this; + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { + test.equal(result, true); + })); + } +]); + +// When we have a rate limited client and we remove the rate limit rule, +// all requests should be allowed immediately afterwards. +testAsyncMulti("ddp rate limiter - test removing rule with rateLimited " + + "client lets them send new queries", [ + function (test, expect) { + _.extend(this, createTestUser(test, expect)); + }, + function (test, expect) { + var self = this; + Meteor.call("addRuleToDDPRateLimiter", expect(function(error, result) { + self.ruleId = result; + })); + }, + function (test, expect) { + Meteor.logout(expect(function (error) { + test.equal(error, undefined); + test.equal(Meteor.user(), null); + })); + }, + function (test, expect) { + var self = this; + // By removing the rule from the DDP rate limiter, we no longer restrict + // them even though they were rate limited + Meteor.call('removeRuleFromDDPRateLimiter', self.ruleId, + expect(function(error, result) { + test.equal(result,true); + })); + }, + function (test, expect) { + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.call.bind(Meteor, 'dummyMethod'), + { + expectedError: undefined, + expectedResult: "yup", + expectedRateLimitWillBeHit: false + } + ); + + callFnMultipleTimesThenExpectResult(test, expect, + Meteor.call.bind(Meteor, 'dummyMethod'), + { + expectedError: undefined, + expectedResult: "yup", + expectedRateLimitWillBeHit: false + } + ); + }, + function (test, expect) { + Meteor.call("removeUserByUsername", this.username, expect(function () {})); + } +]); + +function createTestUser(test, expect) { + const username = Random.id(); + const email = Random.id() + '-intercept@example.com'; + const password = 'password'; + + Accounts.createUser({ + username: username, + email: email, + password: password + }, + expect(function (error, result) { + test.equal(error, undefined); + test.notEqual(Meteor.userId(), null); + })); + + return {username, email, password}; +}; + +/** + * A utility function that runs an arbitrary JavaScript function with a single + * Node-style callback argument multiple times, verifying that the callback is + * fired with certain arguments; then run the function one more time, + * conditionally verifying that the callback is now fired with the "too-many- + * request" rate limit error. + * + * @param test As in testAsyncMulti + * @param expect As in testAsyncMulti + * @param {Function} fn [description] + * @param expectedError expected error before hitting + * rate limit + * @param expectedResult result expected before hitting + * rate limit + * @param {boolean} expectedRateLimitWillBeHit Should we hit rate limit + */ +function callFnMultipleTimesThenExpectResult( + test, expect, fn, {expectedError, expectedResult, expectedRateLimitWillBeHit, + expectedIntervalTimeInMs}) { + + for (var i = 0; i < RATE_LIMIT_NUM_CALLS; i++) { + fn(expect(function (error, result) { + test.equal(error && error.error, expectedError); + test.equal(result, expectedResult); + })); + } + + fn(expect(function (error, result) { + if (expectedRateLimitWillBeHit) { + test.equal(error && error.error, 'too-many-requests', 'error : ' + error); + test.isTrue(error && error.details.timeToReset < + expectedIntervalTimeInMs || RATE_LIMIT_INTERVAL_TIME_MS, 'too long'); + test.equal(result, undefined, 'result is not undefined'); + } else { + test.equal(error && error.error, expectedError); + test.equal(result, expectedResult); + } + })); +} \ No newline at end of file diff --git a/packages/ddp-rate-limiter/ddp-rate-limiter.js b/packages/ddp-rate-limiter/ddp-rate-limiter.js new file mode 100644 index 0000000000..3eb4ac9c26 --- /dev/null +++ b/packages/ddp-rate-limiter/ddp-rate-limiter.js @@ -0,0 +1,93 @@ +// Rate Limiter built into DDP with a default error message. See README or +// online documentation for more details. +DDPRateLimiter = {} + +var errorMessage = function (rateLimitResult) { + return "Error, too many requests. Please slow down. You must wait " + + Math.ceil(rateLimitResult.timeToReset / 1000) + " seconds before " + + "trying again."; +} +var rateLimiter = new RateLimiter(); + +DDPRateLimiter.getErrorMessage = function (rateLimitResult) { + if (typeof errorMessage === 'function') + return errorMessage(rateLimitResult); + else + return errorMessage; +} + +/** + * @summary Set error message text when method or subscription rate limit + * exceeded. + * @param {string|function} message Functions are passed in an object with a + * `timeToReset` field that specifies the number of milliseconds until the next + * method or subscription is allowed to run. The function must return a string + * of the error message. + */ +DDPRateLimiter.setErrorMessage = function (message) { + errorMessage = message; +} + +/** + * @summary + * Add a rule that matches against a stream of events describing method or + * subscription attempts. Each event is an object with the following + * properties: + * + * - `type`: Either "method" or "subscription" + * - `name`: The name of the method or subscription being called + * - `userId`: The user ID attempting the method or subscription + * - `connectionId`: A string representing the user's DDP connection + * - `clientAddress`: The IP address of the user + * + * Returns unique `ruleId` that can be passed to `removeRule`. + * + * @param {Object} matcher + * Matchers specify which events are counted towards a rate limit. A matcher + * is an object that has a subset of the same properties as the event objects + * described above. Each value in a matcher object is one of the following: + * + * - a string: for the event to satisfy the matcher, this value must be equal + * to the value of the same property in the event object + * + * - a function: for the event to satisfy the matcher, the function must + * evaluate to true when passed the value of the same property + * in the event object + * + * Here's how events are counted: Each event that satisfies the matcher's + * filter is mapped to a bucket. Buckets are uniquely determined by the + * event object's values for all properties present in both the matcher and + * event objects. + * + * @param {number} numRequests number of requests allowed per time interval. + * Default = 10. + * @param {number} timeInterval time interval in milliseconds after which + * rule's counters are reset. Default = 1000. + */ +DDPRateLimiter.addRule = function (matcher, numRequests, timeInterval) { + return rateLimiter.addRule(matcher, numRequests, timeInterval); +}; + +DDPRateLimiter.printRules = function () { + return rateLimiter.rules; +} + +/** + * @summary Removes the specified rule from the rate limiter. If rule had + * hit a rate limit, that limit is removed as well. + * @param {string} id 'ruleId' returned from `addRule` + * @return {boolean} True if a rule was removed. + */ +DDPRateLimiter.removeRule = function (id) { + return rateLimiter.removeRule(id); +} + +// This is accessed inside livedata_server.js, but shouldn't be called by any +// user. +DDPRateLimiter._increment = function (input) { + rateLimiter.increment(input); +} + +DDPRateLimiter._check = function (input) { + return rateLimiter.check(input); +} \ No newline at end of file diff --git a/packages/ddp-rate-limiter/package.js b/packages/ddp-rate-limiter/package.js new file mode 100644 index 0000000000..d67ce2ed30 --- /dev/null +++ b/packages/ddp-rate-limiter/package.js @@ -0,0 +1,31 @@ +Package.describe({ + name: 'ddp-rate-limiter', + version: '0.0.1', + // Brief, one-line summary of the package. + summary: 'The DDPRateLimiter allows users to add rate limits to DDP' + + ' methods and subscriptions.', + // URL to the Git repository containing the source code for this package. + git: '', + // By default, Meteor will default to using README.md for documentation. + // To avoid submitting documentation, set this field to null. + documentation: 'README.md' +}); + +Package.onUse(function(api) { +// api.versionsFrom('1.1.0.2'); + api.use('rate-limit'); + api.export('DDPRateLimiter'); + api.addFiles('ddp-rate-limiter.js'); +}); + +Package.onTest(function(api) { + api.use('underscore'); + api.use(['accounts-password', 'tinytest', 'test-helpers', 'tracker', + 'accounts-base', 'random', 'email', 'underscore', 'check', + 'ddp', 'ecmascript', 'es5-shim']); + api.use('ddp-rate-limiter'); + + api.addFiles('ddp-rate-limiter-tests-common.js'); + api.addFiles('ddp-rate-limiter-test-service.js', 'server'); + api.addFiles('ddp-rate-limiter-tests.js', 'client'); +}); diff --git a/packages/ddp-server/livedata_server.js b/packages/ddp-server/livedata_server.js index 7d3ef47818..ee60bd8de0 100644 --- a/packages/ddp-server/livedata_server.js +++ b/packages/ddp-server/livedata_server.js @@ -580,7 +580,37 @@ _.extend(Session.prototype, { // reconnect. return; + // XXX It'd be much better if we had generic hooks where any package can + // hook into subscription handling, but in the mean while we special case + // ddp-rate-limiter package. This is also done for weak requirements to + // add the ddp-rate-limiter package in case we don't have Accounts. A + // user trying to use the ddp-rate-limiter must explicitly require it. + if (Package['ddp-rate-limiter']) { + var DDPRateLimiter = Package['ddp-rate-limiter'].DDPRateLimiter; + var rateLimiterInput = { + userId: self.userId, + clientAddress: self.connectionHandle.clientAddress, + type: "subscription", + name: msg.name, + connectionId: self.id + }; + + DDPRateLimiter._increment(rateLimiterInput); + var rateLimitResult = DDPRateLimiter._check(rateLimiterInput) + if (!rateLimitResult.allowed) { + self.send({ + msg: 'nosub', id: msg.id, + error: new Meteor.Error( + 'too-many-requests', + DDPRateLimiter.getErrorMessage(rateLimitResult), + {timeToReset: rateLimitResult.timeToReset}) + }); + return; + } + } + var handler = self.server.publish_handlers[msg.name]; + self._startSubscription(handler, msg.id, msg.params, msg.name); }, @@ -644,7 +674,31 @@ _.extend(Session.prototype, { connection: self.connectionHandle, randomSeed: randomSeed }); + try { + // XXX It'd be better if we could hook into method handlers better but + // for now, we need to check if the ddp-rate-limiter exists since we + // have a weak requirement for the ddp-rate-limiter package to be added + // to our application. + if (Package['ddp-rate-limiter']) { + var DDPRateLimiter = Package['ddp-rate-limiter'].DDPRateLimiter; + var rateLimiterInput = { + userId: self.userId, + clientAddress: self.connectionHandle.clientAddress, + type: "method", + name: msg.method, + connectionId: self.id + }; + DDPRateLimiter._increment(rateLimiterInput); + var rateLimitResult = DDPRateLimiter._check(rateLimiterInput) + if (!rateLimitResult.allowed) { + throw new Meteor.Error( + "too-many-requests", + DDPRateLimiter.getErrorMessage(rateLimitResult), + {timeToReset: rateLimitResult.timeToReset}); + } + } + var result = DDPServer._CurrentWriteFence.withValue(fence, function () { return DDP._CurrentInvocation.withValue(invocation, function () { return maybeAuditArgumentChecks( diff --git a/packages/ddp-server/package.js b/packages/ddp-server/package.js index 6c7c3ce6b2..427a47ecb3 100644 --- a/packages/ddp-server/package.js +++ b/packages/ddp-server/package.js @@ -15,7 +15,7 @@ Package.onUse(function (api) { // common functionality api.use('ddp-common', 'server'); // heartbeat - + api.use('ddp-rate-limiter', 'server', {weak: true}); // Transport api.use('ddp-client', 'server'); api.imply('ddp-client'); diff --git a/packages/rate-limit/README.md b/packages/rate-limit/README.md new file mode 100644 index 0000000000..5a46421e59 --- /dev/null +++ b/packages/rate-limit/README.md @@ -0,0 +1,72 @@ +# Rate Limit +=== +A Rate Limiter is a general rate limiting object that stores rules and +determines whether inputs are allowed based on the rules. There is also a +general structure of Rules which contain all the internal state of a rule. + +Rate limiters analyze a series of "inputs" (which are POJOs) by running them +against a set of "rules." Rules specify which inputs they match by running +configurable "matcher" functions on keys in the input object. A `check` method +returns whether this input should be allowed, the time until the rate limit is +reset and the number of calls remaining for this input. The count of processed +inputs are kept in a dictionary of counters stored inside each rule, keyed by +a unique string composed of the input that matched to the rule. + +### Rule Structure + +Each rule is composed of an `id`, an options object that contains the ` +intervalTime` in milliseconds after which the rule is reset and +`numRequestsAllowed` in the specified interval time, a dictionary of `matchers` +whose keys are searched for in the input to determine if there is a match. If +the values match, then the rule's counters are incremented. Values can be +objects or they can be functions that return a boolean of whether the +provided input matches. For example, if we only want to match all even ids, +plus any other fields, we could have a rule that included a key-value pair as +follows: + +```javascript +{ + ... + id: function (id) { + return id % 2 === 0; + }, + ... +} +``` +A rule is only said to apply to a given input if every key in the matcher +matches to the input values. There is also a dictionary of `counters` that +store the current state of inputs and number of times they've been passed to +the rate limiter. Each rule defines a domain of keys and values that it +applies to, and we want to have a unique way of recording each input provided +to the Rate Limiter that matches to the rule. Say a rule inspects a methodName +property and a username property. We want to count how many times each user +called a certain method and restrict them to a certain number of calls per +user defined time frame. So we generate a unique string key (to be used as +keys in a counters object) to represent each specific methodName + user +combination. Since this rule applies to multiple user, we need to concatenate +the differnet input key names with their values. For example, if we had a rule +with matchers as such: + +```javascript +{ + username: function(username) { + return true; + }, + methodName: 'hello' +} +``` +and we were passed an input as follows: + +``` +{ + username: 'meteor' + methodName: 'hello' +} +``` +The key generated would be 'usernamemeteormethodNamehello'. This is guaranteed +to be unique for this username+methodName combination. These keys are cleared +every time the intervalTime is passed, at which point we delete the current +dictionary of counters we store. Every time a rule matches to an input, we +determine the unique key string and check if it's counters have exceeded the +allowed amounts, returning an error to the user letting them know that a rate +limit has been reached. \ No newline at end of file diff --git a/packages/rate-limit/package.js b/packages/rate-limit/package.js new file mode 100644 index 0000000000..950efed10d --- /dev/null +++ b/packages/rate-limit/package.js @@ -0,0 +1,29 @@ +Package.describe({ + name: 'rate-limit', + version: '0.0.1', + // Brief, one-line summary of the package. + summary: '', + // URL to the Git repository containing the source code for this package. + git: '', + // By default, Meteor will default to using README.md for documentation. + // To avoid submitting documentation, set this field to null. + documentation: 'README.md' +}); + +Package.onUse(function(api) { + api.use('underscore'); + api.use('random'); + api.addFiles('rate-limit.js'); + api.export("RateLimiter"); +}); + +Package.onTest(function(api) { + api.use('test-helpers', ['client', 'server']); + api.use('underscore'); + api.use('random'); + api.use('ddp-rate-limiter'); + api.use('tinytest'); + api.use('rate-limit'); + api.use('ddp-common'); + api.addFiles('rate-limit-tests.js'); +}); diff --git a/packages/rate-limit/rate-limit-tests.js b/packages/rate-limit/rate-limit-tests.js new file mode 100644 index 0000000000..e59ef6a72f --- /dev/null +++ b/packages/rate-limit/rate-limit-tests.js @@ -0,0 +1,365 @@ +// These tests were written before rate-limit was factored outside of DDP Rate +// Limiter and thus are structured with DDP method invocations in mind. These +// rules still test abstract rate limit package behavior. The tests currently +// implemented are: +// * Empty rule set on RateLimiter construction +// * Multiple inputs, only 1 that matches rule and reaches rate limit +// * Multiple inputs, 1 hits rate limit, wait for reset, after which inputs +// allowed +// * 2 rules, 3 inputs where 2/3 match 1 rule and thus hit rate limit. Second +// input matches another rule and hits rate limit while 3rd rule not rate +// limited +// * One rule affected by two inputs still throws +// * Global rule triggers on any invocation after reaching limit +// * Fuzzy rule matching triggers rate limit only when input has more keys than +// rule +// * matchRule tests that have various levels of similarity in input and rule +// * generateKeyString tests for various matches creating appropriate string +// +// XXX These tests should be refactored to use Tinytest.add instead of +// testAsyncMulti as they're all on the server. Any future tests should be +// written that way. +Tinytest.add('rate limit tests - Check empty constructor creation', + function (test) { + r = new RateLimiter(); + test.equal(r.rules, {}); +}); + +Tinytest.add('rate limit tests - Check single rule with multiple ' + + 'invocations, only 1 that matches', + function (test) { + r = new RateLimiter(); + var userIdOne = 1; + var restrictJustUserIdOneRule = { + userId: myUserId, + IPAddr: null, + method: null + }; + + r.addRule(restrictJustUserId1Rule, 1, 1000); + var connectionHandle = createTempConnectionHandle(123, '127.0.0.1'); + var methodInvc1 = createTempMethodInvocation(userIdOne, connectionHandle, + 'login'); + var methodInvc2 = createTempMethodInvocation(2, connectionHandle, + 'login'); + for (var i = 0; i < 2; i++) { + r.increment(methodInvc1); + r.increment(methodInvc2); + } + test.equal(r.check(methodInvc1).allowed, false); + test.equal(r.check(methodInvc2).allowed, true); + }); + +testAsyncMulti("rate limit tests - Run multiple invocations and wait for one" + + " to reset", [ + function (test, expect) { + var self = this; + self.r = new RateLimiter(); + self.userIdOne = 1; + self.userIdTwo = 2; + self.restrictJustUserIdOneRule = { + userId: myUserId, + IPAddr: null, + method: null + }; + self.r.addRule(self.restrictJustUserIdOneRule, 1, 1000); + self.connectionHandle = createTempConnectionHandle(123, '127.0.0.1') + self.methodInvc1 = createTempMethodInvocation(self.userIdOne, + self.connectionHandle, 'login'); + self.methodInvc2 = createTempMethodInvocation(self.userIdTwo, + self.connectionHandle, 'login'); + for (var i = 0; i < 2; i++) { + self.r.increment(self.methodInvc1); + self.r.increment(self.methodInvc2); + } + test.equal(self.r.check(self.methodInvc1).allowed, false); + test.equal(self.r.check(self.methodInvc2).allowed, true); + Meteor.setTimeout(expect(function () {}), 1000); + }, + function (test, expect) { + var self = this; + for (var i = 0; i < 100; i++) { + self.r.increment(self.methodInvc2); + } + + test.equal(self.r.check(self.methodInvc1).allowed, true); + test.equal(self.r.check(self.methodInvc2).allowed, true); + } +]); + +Tinytest.add('rate limit tests - Check two rules that affect same methodInvc' + + ' still throw', + function (test) { + r = new RateLimiter(); + var loginMethodRule = { + userId: null, + IPAddr: null, + method: 'login' + }; + var onlyLimitEvenUserIdRule = { + userId: function (userId) { + return userId % 2 === 0 + }, + IPAddr: null, + method: null + }; + r.addRule(loginMethodRule, 10, 100); + r.addRule(onlyLimitEvenUserIdRule, 4, 100); + + var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); + var methodInvc1 = createTempMethodInvocation(1, connectionHandle, + 'login'); + var methodInvc2 = createTempMethodInvocation(2, connectionHandle, + 'login'); + var methodInvc3 = createTempMethodInvocation(3, connectionHandle, + 'test'); + + for (var i = 0; i < 5; i++) { + r.increment(methodInvc1); + r.increment(methodInvc2); + r.increment(methodInvc3); + }; + + // After for loop runs, we only have 10 runs, so that's under the limit + test.equal(r.check(methodInvc1).allowed, true); + // However, this triggers userId rule since this userId is even + test.equal(r.check(methodInvc2).allowed, false); + test.equal(r.check(methodInvc2).allowed, false); + + // Running one more test causes it to be false, since we're at 11 now. + r.increment(methodInvc1); + test.equal(r.check(methodInvc1).allowed, false); + // 3rd Method Invocation isn't affected by either rules. + test.equal(r.check(methodInvc3).allowed, true); + + }); + +Tinytest.add('rate limit tests - Check one rule affected by two different ' + + 'invocations', + function (test) { + r = new RateLimiter(); + var loginMethodRule = { + userId: null, + IPAddr: null, + method: 'login' + } + r.addRule(loginMethodRule, 10, 10000); + + var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); + var methodInvc1 = createTempMethodInvocation(1, connectionHandle, + 'login'); + var methodInvc2 = createTempMethodInvocation(2, connectionHandle, + 'login'); + + for (var i = 0; i < 5; i++) { + r.increment(methodInvc1); + r.increment(methodInvc2); + } + // This throws us over the limit since both increment the login rule + // counter + r.increment(methodInvc1); + + test.equal(r.check(methodInvc1).allowed, false); + test.equal(r.check(methodInvc2).allowed, false); + }); + +Tinytest.add("rate limit tests - add global rule", function (test) { + r = new RateLimiter(); + var globalRule = { + userId: null, + IPAddr: null, + method: null + } + r.addRule(globalRule, 1, 10000); + + var connectionHandle = createTempConnectionHandle(1234, '127.0.0.1'); + var connectionHandle2 = createTempConnectionHandle(1234, '127.0.0.2'); + + var methodInvc1 = createTempMethodInvocation(1, connectionHandle, + 'login'); + var methodInvc2 = createTempMethodInvocation(2, connectionHandle2, + 'test'); + var methodInvc3 = createTempMethodInvocation(3, connectionHandle, + 'user-accounts'); + + // First invocation, all methods would still be allowed. + r.increment(methodInvc2); + test.equal(r.check(methodInvc1).allowed, true); + test.equal(r.check(methodInvc2).allowed, true); + test.equal(r.check(methodInvc3).allowed, true); + // Second invocation, everything has reached common rate limit + r.increment(methodInvc3); + test.equal(r.check(methodInvc1).allowed, false); + test.equal(r.check(methodInvc2).allowed, false); + test.equal(r.check(methodInvc3).allowed, false); +}); + +Tinytest.add('rate limit tests - Fuzzy rule match does not trigger rate limit', + function (test) { + r = new RateLimiter(); + var rule = { + a: function (inp) { + return inp % 3 == 0 + }, + b: 5, + c: "hi", + } + r.addRule(rule, 1, 10000); + var input = { + a: 3, + b: 5 + } + for (var i = 0; i < 5; i++) { + r.increment(input); + } + test.equal(r.check(input).allowed, true); + var matchingInput = { + a: 3, + b: 5, + c: "hi", + d: 1 + } + r.increment(matchingInput); + r.increment(matchingInput); + // Past limit so should be false + test.equal(r.check(matchingInput).allowed, false); + + // Add secondary rule and check that longer time is returned when multiple + // rules limits are hit + var newRule = { + a: function (inp) { + return inp % 3 == 0 + }, + b: 5, + c: "hi", + d: 1 + } + r.addRule(newRule, 1, 10); + // First rule should still throw while second rule will trigger as well, + // causing us to return longer time to reset to user + r.increment(matchingInput); + r.increment(matchingInput); + test.equal(r.check(matchingInput).timeToReset > 50, true); + } +); + + +/****** Test Our Helper Methods *****/ + +Tinytest.add("rate limit tests - test matchRule method", function (test) { + r = new RateLimiter(); + var globalRule = { + userId: null, + IPAddr: null, + type: null, + name: null + } + var globalRuleId = r.addRule(globalRule); + + var rateLimiterInput = { + userId: 1023, + IPAddr: "127.0.0.1", + type: 'sub', + name: 'getSubLists' + }; + + test.equal(r.rules[globalRuleId].match(rateLimiterInput), true); + + var oneNotNullRule = { + userId: 102, + IPAddr: null, + type: null, + name: null + } + + var oneNotNullId = r.addRule(oneNotNullRule); + test.equal(r.rules[oneNotNullId].match(RateLimiterInput), false); + + oneNotNullRule.userId = 1023; + test.equal(r.rules[oneNotNullId].match(RateLimiterInput), true); + + var notCompleteInput = { + userId: 102, + IPAddr: '127.0.0.1' + }; + test.equal(r.rules[globalRuleId].match(notCompleteInput), true); + test.equal(r.rules[oneNotNullId].match(notCompleteInput), false); +}); + +Tinytest.add('rate limit tests - test generateMethodKey string', + function (test) { + r = new RateLimiter(); + var globalRule = { + userId: null, + IPAddr: null, + type: null, + name: null + } + var globalRuleId = r.addRule(globalRule); + + var rateLimiterInput = { + userId: 1023, + IPAddr: "127.0.0.1", + type: 'sub', + name: 'getSubLists' + }; + + test.equal(r.rules[globalRuleId]._generateKeyString(rateLimiterInput), ""); + globalRule.userId = 1023; + + test.equal(r.rules[globalRuleId]._generateKeyString(rateLimiterInput), + "userId1023"); + + var ruleWithFuncs = { + userId: function (input) { + return input % 2 === 0 + }, + IPAddr: null, + type: null + }; + var funcRuleId = r.addRule(ruleWithFuncs); + test.equal(r.rules[funcRuleId]._generateKeyString(rateLimiterInput), ""); + rateLimiterInput.userId = 1024; + test.equal(r.rules[funcRuleId]._generateKeyString(rateLimiterInput), + "userId1024"); + + var multipleRules = ruleWithFuncs; + multipleRules.IPAddr = '127.0.0.1'; + var multipleRuleId = r.addRule(multipleRules); + test.equal(r.rules[multipleRuleId]._generateKeyString(rateLimiterInput), + "userId1024IPAddr127.0.0.1") + } +); + +function createTempConnectionHandle(id, clientIP) { + return { + id: id, + close: function () { + self.close(); + }, + onClose: function (fn) { + var cb = Meteor.bindEnvironment(fn, "connection onClose callback"); + if (self.inQueue) { + self._closeCallbacks.push(cb); + } else { + // if we're already closed, call the callback. + Meteor.defer(cb); + } + }, + clientAddress: clientIP, + httpHeaders: null + }; +} + +function createTempMethodInvocation(userId, connectionHandle, methodName) { + var methodInv = new DDPCommon.MethodInvocation({ + isSimulation: false, + userId: userId, + setUserId: null, + unblock: false, + connection: connectionHandle, + randomSeed: 1234 + }); + methodInv.method = methodName; + return methodInv; +} \ No newline at end of file diff --git a/packages/rate-limit/rate-limit.js b/packages/rate-limit/rate-limit.js new file mode 100644 index 0000000000..9324b00af3 --- /dev/null +++ b/packages/rate-limit/rate-limit.js @@ -0,0 +1,255 @@ +// Default time interval (in milliseconds) to reset rate limit counters +var DEFAULT_INTERVAL_TIME_IN_MILLISECONDS = 1000; +// Default number of events allowed per time interval +var DEFAULT_REQUESTS_PER_INTERVAL = 10; + +// A rule is defined by an options object that contains two fields, +// `numRequestsAllowed` which is the number of events allowed per interval, and +// an `intervalTime` which is the amount of time in milliseconds before the +// rate limit restarts its internal counters, and by a matchers object. A +// matchers object is a POJO that contains a set of keys with values that +// define the entire set of inputs that match for each key. The values can +// either be null (optional), a primitive or a function that returns a boolean +// of whether the provided input's value matches for this key. +// +// Rules are uniquely assigned an `id` and they store a dictionary of counters, +// which are records used to keep track of inputs that match the rule. If a +// counter reaches the `numRequestsAllowed` within a given `intervalTime`, a +// rate limit is reached and future inputs that map to that counter will +// result in errors being returned to the client. +var Rule = function (options, matchers) { + var self = this; + + self.id = Random.id(); + + self.options = options; + + self._matchers = matchers; + + self._lastResetTime = new Date().getTime(); + + // Dictionary of input keys to counters + self.counters = {}; +}; + +_.extend(Rule.prototype, { + // Determine if this rule applies to the given input by comparing all + // rule.matchers. If the match fails, search short circuits instead of + // iterating through all matchers. + match: function (input) { + var self = this; + var ruleMatches = true; + return _.every(self._matchers, function (matcher, key) { + if (matcher !== null) { + if (!(_.has(input,key))) { + return false; + } else { + if (typeof matcher === 'function') { + if (!(matcher(input[key]))) { + return false; + } + } else { + if (matcher !== input[key]) { + return false; + } + } + } + } + return true; + }); + }, + + // Generates unique key string for provided input by concatenating all the + // keys in the matcher with the corresponding values in the input. + // Only called if rule matches input. + _generateKeyString: function (input) { + var self = this; + var returnString = ""; + _.each(self._matchers, function (matcher, key) { + if (matcher !== null) { + if (typeof matcher === 'function') { + if (matcher(input[key])) { + returnString += key + input[key]; + } + } else { + returnString += key + input[key]; + } + } + }); + return returnString; + }, + + // Applies the provided input and returns the key string, time since counters + // were last reset and time to next reset. + apply: function (input) { + var self = this; + var keyString = self._generateKeyString(input); + var timeSinceLastReset = new Date().getTime() - self._lastResetTime; + var timeToNextReset = self.options.intervalTime - timeSinceLastReset; + return { + key: keyString, + timeSinceLastReset: timeSinceLastReset, + timeToNextReset: timeToNextReset + }; + }, + // Reset counter dictionary for this specific rule. Called once the + // timeSinceLastReset has exceeded the intervalTime. _lastResetTime is + // set to be the current time in milliseconds. + resetCounter: function () { + var self = this; + + // Delete the old counters dictionary to allow for garbage collection + self.counters = {}; + self._lastResetTime = new Date().getTime(); + } +}); + +// Initialize rules to be an empty dictionary. +RateLimiter = function () { + var self = this; + + // Dictionary of all rules associated with this RateLimiter, keyed by their + // id. Each rule object stores the rule pattern, number of events allowed, + // last reset time and the rule reset interval in milliseconds. + self.rules = {}; +} + +/** + * Checks if this input has exceeded any rate limits. + * @param {object} input dictionary containing key-value pairs of attributes + * that match to rules + * @return {object} Returns object of following structure + * { 'allowed': boolean - is this input allowed + * 'timeToReset': integer | Infinity - returns time until counters are reset + * in milliseconds + * 'numInvocationsLeft': integer | Infinity - returns number of calls left + * before limit is reached + * } + * If multiple rules match, the least number of invocations left is returned. + * If the rate limit has been reached, the longest timeToReset is returned. + */ +RateLimiter.prototype.check = function (input) { + var self = this; + var reply = { + allowed: true, + timeToReset: 0, + numInvocationsLeft: Infinity + }; + + var matchedRules = self._findAllMatchingRules(input); + _.each(matchedRules, function (rule) { + var ruleResult = rule.apply(input); + var 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; + } 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; + } + } + }); + return reply; +} + +/** + * Adds a rule to dictionary of rules that are checked against on every call. + * Only inputs that pass all of the rules will be allowed. Returns unique rule + * id that can be passed to `removeRule`. + * @param {object} rule Input dictionary defining certain attributes and + * rules associated with them. + * Each attribute's value can either be a value, a function or null. All + * functions must return a boolean of whether the input is matched by that + * attribute's rule or not + * @param {integer} numRequestsAllowed Optional. Number of events allowed per + * interval. Default = 10. + * @param {integer} intervalTime Optional. Number of milliseconds before + * rule's counters are reset. Default = 1000. + * @return {string} Returns unique rule id + */ +RateLimiter.prototype.addRule = function (rule, numRequestsAllowed, + intervalTime) { + var self = this; + + var options = { + numRequestsAllowed: numRequestsAllowed || DEFAULT_REQUESTS_PER_INTERVAL, + intervalTime: intervalTime || DEFAULT_INTERVAL_TIME_IN_MILLISECONDS + } + + var newRule = new Rule(options, rule); + this.rules[newRule.id] = newRule; + return newRule.id; +} + +/** + * Increment counters in every rule that match to this input + * @param {object} input Dictionary object containing attributes that may + * match to rules + */ +RateLimiter.prototype.increment = function (input) { + var self = this; + + // Only increment rule counters that match this input + var matchedRules = self._findAllMatchingRules(input); + _.each(matchedRules, function (rule) { + var ruleResult = rule.apply(input); + + if (ruleResult.timeSinceLastReset > rule.options.intervalTime) { + // Reset all the counters since the rule has reset + rule.resetCounter(); + } + + // Check whether the key exists, incrementing it if so or otherwise + // adding the key and setting its value to 1 + if (_.has(rule.counters, ruleResult.key)) + rule.counters[ruleResult.key]++; + else + rule.counters[ruleResult.key] = 1; + }); +} + +// Returns an array of all rules that apply to provided input +RateLimiter.prototype._findAllMatchingRules = function (input) { + var self = this; + + return _.filter(self.rules, function(rule) { + return rule.match(input); + }); +} +/** + * Provides a mechanism to remove rules from the rate limiter. Returns boolean + * about success. + * @param {string} id Rule id returned from #addRule + * @return {boolean} Returns true if rule was found and deleted, else false. + */ +RateLimiter.prototype.removeRule = function (id) { + var self = this; + if (self.rules[id]) { + delete self.rules[id]; + return true; + } else { + return false; + } +} \ No newline at end of file