hof 20.0.0-beta.6 → 20.0.0-beta.9

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. package/.github/workflows/automate-publish.yml +1 -1
  2. package/.github/workflows/automate-tag.yml +1 -1
  3. package/.nyc_output/e2fdc3eb-4fd2-47e0-a392-fe5f665776a4.json +1 -0
  4. package/.nyc_output/processinfo/e2fdc3eb-4fd2-47e0-a392-fe5f665776a4.json +1 -0
  5. package/.nyc_output/processinfo/index.json +1 -1
  6. package/build/lib/mkdir.js +2 -2
  7. package/components/date/index.js +37 -26
  8. package/components/date/templates/date.html +3 -3
  9. package/components/emailer/index.js +49 -41
  10. package/components/emailer/transports/debug.js +1 -2
  11. package/components/summary/index.js +18 -0
  12. package/config/hof-defaults.js +5 -3
  13. package/config/rate-limits.js +20 -0
  14. package/config/sanitisation-rules.js +29 -0
  15. package/controller/base-controller.js +26 -8
  16. package/controller/controller.js +11 -15
  17. package/frontend/govuk-template/build/config.js +1 -1
  18. package/frontend/template-mixins/mixins/template-mixins.js +5 -5
  19. package/frontend/template-mixins/partials/forms/checkbox-group.html +3 -3
  20. package/frontend/template-mixins/partials/forms/input-text-group.html +2 -2
  21. package/frontend/template-mixins/partials/forms/option-group.html +3 -3
  22. package/frontend/template-mixins/partials/forms/select.html +2 -2
  23. package/frontend/template-mixins/partials/forms/textarea-group.html +2 -2
  24. package/frontend/template-mixins/partials/mixins/panel.html +1 -2
  25. package/frontend/template-partials/translations/src/en/errors.json +12 -0
  26. package/frontend/template-partials/views/rate-limit-error.html +10 -0
  27. package/frontend/themes/gov-uk/client-js/govuk-cookies.js +43 -44
  28. package/frontend/themes/gov-uk/client-js/index.js +2 -2
  29. package/frontend/themes/gov-uk/client-js/skip-to-main.js +18 -17
  30. package/index.js +9 -4
  31. package/lib/router.js +2 -1
  32. package/lib/settings.js +9 -8
  33. package/middleware/errors.js +32 -0
  34. package/middleware/index.js +2 -1
  35. package/middleware/rate-limiter.js +98 -0
  36. package/package.json +6 -7
  37. package/sandbox/apps/sandbox/fields.js +11 -12
  38. package/sandbox/server.js +5 -0
  39. package/transpiler/lib/write-files.js +1 -2
  40. package/utilities/helpers/index.js +16 -1
  41. package/wizard/index.js +1 -0
  42. package/.nyc_output/65af88d9-aebe-4d1b-a21d-6fbf7f2bbda4.json +0 -1
  43. package/.nyc_output/processinfo/65af88d9-aebe-4d1b-a21d-6fbf7f2bbda4.json +0 -1
@@ -1,9 +1,9 @@
1
1
  <div id="{{key}}-group" class="govuk-form-group{{#className}} {{className}} {{/className}}{{#formGroupClassName}} {{formGroupClassName}}{{/formGroupClassName}}{{#error}} govuk-form-group--error{{/error}}">
2
2
  <fieldset class="govuk-fieldset" {{#hint}} aria-describedby="{{key}}-hint"{{/hint}}>
3
- <legend class="govuk-fieldset__legend {{^noHeading}}govuk-fieldset__legend--l{{/noHeading}}{{#legendClassName}} {{legendClassName}}{{/legendClassName}}">
4
- {{^noHeading}}<h1 class="govuk-fieldset__heading">{{/noHeading}}
3
+ <legend class="govuk-fieldset__legend {{#isPageHeading}}govuk-fieldset__legend--l{{/isPageHeading}}{{#legendClassName}} {{legendClassName}}{{/legendClassName}}">
4
+ {{#isPageHeading}}<h1 class="govuk-fieldset__heading">{{/isPageHeading}}
5
5
  {{legend}}
6
- {{^noHeading}}</h1>{{/noHeading}}
6
+ {{#isPageHeading}}</h1>{{/isPageHeading}}
7
7
  </legend>
8
8
  {{#hint}}<div id="{{key}}-hint" class="govuk-hint">{{hint}}</div>{{/hint}}
9
9
  {{#error}}
@@ -1,8 +1,8 @@
1
1
  <div id="{{id}}-group" class="{{#compound}} form-group-compound{{/compound}}{{#formGroupClassName}}{{formGroupClassName}}{{/formGroupClassName}}{{#error}} govuk-form-group--error{{/error}}">
2
- {{^noHeading}}<h1 class="govuk-label-wrapper">{{/noHeading}}<label for="{{id}}" class="{{labelClassName}}">
2
+ {{#isPageHeading}}<h1 class="govuk-label-wrapper">{{/isPageHeading}}<label for="{{id}}" class="{{labelClassName}}">
3
3
  {{{label}}}
4
4
  </label>
5
- {{^noHeading}}</h1>{{/noHeading}}
5
+ {{#isPageHeading}}</h1>{{/isPageHeading}}
6
6
  {{#hint}}<span {{$hintId}}id="{{hintId}}" {{/hintId}}class="govuk-hint">{{hint}}</span>{{/hint}}
7
7
  {{#error}}
8
8
  <p class="govuk-error-message">
@@ -1,9 +1,9 @@
1
1
  <div id="{{key}}-group" class="govuk-form-group{{#className}} {{className}} {{/className}}{{#formGroupClassName}} {{formGroupClassName}}{{/formGroupClassName}}{{#error}} govuk-form-group--error{{/error}}">
2
2
  <fieldset class="govuk-fieldset" {{#hint}} aria-describedby="{{key}}-hint"{{/hint}}>
3
- <legend class="govuk-fieldset__legend {{^noHeading}}govuk-fieldset__legend--l{{/noHeading}}{{#legendClassName}} {{legendClassName}}{{/legendClassName}}">
4
- {{^noHeading}}<h1 class="govuk-fieldset__heading">{{/noHeading}}
3
+ <legend class="govuk-fieldset__legend {{#isPageHeading}}govuk-fieldset__legend--l{{/isPageHeading}}{{#legendClassName}} {{legendClassName}}{{/legendClassName}}">
4
+ {{#isPageHeading}}<h1 class="govuk-fieldset__heading">{{/isPageHeading}}
5
5
  {{legend}}
6
- {{^noHeading}}</h1>{{/noHeading}}
6
+ {{#isPageHeading}}</h1>{{/isPageHeading}}
7
7
  </legend>
8
8
  {{#hint}}<div id="{{key}}-hint" class="govuk-hint">{{hint}}</div>{{/hint}}
9
9
  {{#error}}<p id="{{key}}-error" class="govuk-error-message"><span class="govuk-visually-hidden">Error:</span> {{error.message}}</p>{{/error}}
@@ -1,5 +1,5 @@
1
1
  <div id="{{id}}-group" class="{{#compound}} form-group-compound{{/compound}}{{#formGroupClassName}} {{formGroupClassName}}{{/formGroupClassName}}{{#error}} govuk-form-group--error{{/error}}">
2
- {{^noHeading}}<h1 class="govuk-label-wrapper">{{/noHeading}}<label for="{{id}}" class="{{labelClassName}}{{^noHeading}}govuk-label--l{{/noHeading}}">
2
+ {{#isPageHeading}}<h1 class="govuk-label-wrapper">{{/isPageHeading}}<label for="{{id}}" class="{{labelClassName}}{{#isPageHeading}}govuk-label--l{{/isPageHeading}}">
3
3
  {{{label}}}
4
4
  {{#hint}}<span {{$hintId}}id="{{hintId}}" {{/hintId}}class="govuk-hint">{{hint}}</span>{{/hint}}
5
5
  {{#error}}
@@ -8,7 +8,7 @@
8
8
  </p>
9
9
  {{/error}}
10
10
  </label>
11
- {{^noHeading}}</h1>{{/noHeading}}
11
+ {{#isPageHeading}}</h1>{{/isPageHeading}}
12
12
  <select id="{{id}}" class="govuk-select{{#className}} {{className}}{{/className}}{{#error}} invalid-input{{/error}}" name="{{id}}" aria-required="{{required}}">
13
13
  {{#options}}
14
14
  <option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option>
@@ -2,7 +2,7 @@
2
2
  <div class="govuk-character-count" data-module="govuk-character-count" data-maxlength="{{maxlength}}">
3
3
  {{/maxlength}}
4
4
  <div id="{{id}}-group" class="{{#compound}}form-group-compound {{/compound}}{{#formGroupClassName}}{{formGroupClassName}}{{/formGroupClassName}}{{#error}} govuk-form-group--error{{/error}}">
5
- {{^noHeading}}<h1 class="govuk-label-wrapper">{{/noHeading}}<label for="{{id}}" class="{{labelClassName}}">
5
+ {{#isPageHeading}}<h1 class="govuk-label-wrapper">{{/isPageHeading}}<label for="{{id}}" class="{{labelClassName}}">
6
6
  {{{label}}}
7
7
  {{#error}}
8
8
  <p id="{{id}}-error" class="govuk-error-message">
@@ -10,7 +10,7 @@
10
10
  </p>
11
11
  {{/error}}
12
12
  </label>
13
- {{^noHeading}}</h1>{{/noHeading}}
13
+ {{#isPageHeading}}</h1>{{/isPageHeading}}
14
14
  {{#hint}}<div {{$hintId}}id="{{hintId}}" {{/hintId}}class="govuk-hint">{{hint}}</div>{{/hint}}
15
15
  {{#renderChild}}{{/renderChild}}
16
16
  <textarea
@@ -1,5 +1,4 @@
1
- <div id="{{toggle}}-panel"
2
- class="{{#radioOption}}govuk-radios__conditional govuk-radios__conditional--hidden{{/radioOption}}
1
+ <div id="{{toggle}}-panel" class="{{#radioOption}}govuk-radios__conditional govuk-radios__conditional--hidden{{/radioOption}}
3
2
  {{^radioOption}}govuk-checkboxes__conditional govuk-checkboxes__conditional--hidden{{/radioOption}}">
4
3
  {{#renderMixin}}{{/renderMixin}}
5
4
  </div>
@@ -14,5 +14,17 @@
14
14
  "cookies-required": {
15
15
  "title": "Cookies are required to use this service",
16
16
  "message": "Cookies are required in order to use this service.<br /><br /> Please <a href=\"http://www.aboutcookies.org/how-to-control-cookies/\" rel=\"external\">enable cookies</a> and try again. Find out <a href=\"/cookies\">how to we use cookies</a>."
17
+ },
18
+ "ddos-rate-limit": {
19
+ "title": "Too many requests submitted",
20
+ "message": "You have submitted too many requests in quick succession.",
21
+ "pre-time-to-wait": "Please try again in ",
22
+ "post-time-to-wait": " minutes."
23
+ },
24
+ "submission-rate-limit": {
25
+ "title": "Too many submissions",
26
+ "message": "You have submitted too many applications in a short space of time.",
27
+ "pre-time-to-wait": "Please try again in ",
28
+ "post-time-to-wait": " minutes."
17
29
  }
18
30
  }
@@ -0,0 +1,10 @@
1
+ {{<layout}}
2
+ {{$header}}
3
+ {{content.title}}
4
+ {{/header}}
5
+ {{$content}}
6
+ <p>{{content.message}}</p>
7
+ <p>{{content.preTimeToWait}}{{content.timeToWait}}{{content.postTimeToWait}}</p>
8
+ <a href="/" class="button" role="button">{{#t}}buttons.try-again{{/t}}</a>
9
+ {{/content}}
10
+ {{/layout}}
@@ -1,6 +1,7 @@
1
+ /* eslint-disable no-undef, no-param-reassign, no-unused-vars */
1
2
  (function () {
2
- "use strict"
3
- var root = this;
3
+ 'use strict';
4
+ const root = this;
4
5
  if(typeof root.GOVUK === 'undefined') { root.GOVUK = {}; }
5
6
 
6
7
  /*
@@ -19,37 +20,35 @@
19
20
  GOVUK.cookie('hobnob', null);
20
21
  */
21
22
  GOVUK.cookie = function (name, value, options) {
22
- if(typeof value !== 'undefined'){
23
+ if(typeof value !== 'undefined') {
23
24
  if(value === false || value === null) {
24
25
  return GOVUK.setCookie(name, '', { days: -1 });
25
- } else {
26
- return GOVUK.setCookie(name, value, options);
27
26
  }
28
- } else {
29
- return GOVUK.getCookie(name);
27
+ return GOVUK.setCookie(name, value, options);
30
28
  }
29
+ return GOVUK.getCookie(name);
31
30
  };
32
31
  GOVUK.setCookie = function (name, value, options) {
33
32
  if(typeof options === 'undefined') {
34
33
  options = {};
35
34
  }
36
- var cookieString = name + "=" + value + "; path=/";
35
+ let cookieString = name + '=' + value + '; path=/';
37
36
  if (options.days) {
38
- var date = new Date();
37
+ const date = new Date();
39
38
  date.setTime(date.getTime() + (options.days * 24 * 60 * 60 * 1000));
40
- cookieString = cookieString + "; expires=" + date.toGMTString();
39
+ cookieString = cookieString + '; expires=' + date.toGMTString();
41
40
  }
42
- if (document.location.protocol == 'https:'){
43
- cookieString = cookieString + "; Secure";
41
+ if (document.location.protocol === 'https:') {
42
+ cookieString = cookieString + '; Secure';
44
43
  }
45
44
  document.cookie = cookieString;
46
45
  };
47
46
  GOVUK.getCookie = function (name) {
48
- var nameEQ = name + "=";
49
- var cookies = document.cookie.split(';');
50
- for(var i = 0, len = cookies.length; i < len; i++) {
51
- var cookie = cookies[i];
52
- while (cookie.charAt(0) == ' ') {
47
+ const nameEQ = name + '=';
48
+ const cookies = document.cookie.split(';');
49
+ for(let i = 0, len = cookies.length; i < len; i++) {
50
+ let cookie = cookies[i];
51
+ while (cookie.charAt(0) === ' ') {
53
52
  cookie = cookie.substring(1, cookie.length);
54
53
  }
55
54
  if (cookie.indexOf(nameEQ) === 0) {
@@ -60,33 +59,33 @@
60
59
  };
61
60
  }).call(this);
62
61
  (function () {
63
- 'use strict'
64
- var root = this
65
- if (typeof root.GOVUK === 'undefined') { root.GOVUK = {} }
62
+ 'use strict';
63
+ const root = this;
64
+ if (typeof root.GOVUK === 'undefined') { root.GOVUK = {}; }
66
65
 
67
66
  GOVUK.addCookieMessage = function () {
68
- var message = document.getElementById('global-cookie-message')
67
+ const message = document.getElementById('global-cookie-message');
69
68
 
70
- var hasCookieMessage = (message && GOVUK.cookie('seen_cookie_message') === null)
69
+ const hasCookieMessage = (message && GOVUK.cookie('seen_cookie_message') === null);
71
70
 
72
71
  if (hasCookieMessage) {
73
- message.style.display = 'block'
74
- GOVUK.cookie('seen_cookie_message', 'yes', { days: 28 })
72
+ message.style.display = 'block';
73
+ GOVUK.cookie('seen_cookie_message', 'yes', { days: 28 });
75
74
 
76
75
  document.addEventListener('DOMContentLoaded', function (event) {
77
76
  if (GOVUK.analytics && typeof GOVUK.analytics.trackEvent === 'function') {
78
77
  GOVUK.analytics.trackEvent('cookieBanner', 'Cookie banner shown', {
79
78
  value: 1,
80
79
  nonInteraction: true
81
- })
80
+ });
82
81
  }
83
- })
84
- };
85
- }
82
+ });
83
+ }
84
+ };
86
85
  }).call(this)
87
86
  ;
88
- (function() {
89
- "use strict"
87
+ (function () {
88
+ 'use strict';
90
89
 
91
90
  // add cookie message
92
91
  if (window.GOVUK && GOVUK.addCookieMessage) {
@@ -94,28 +93,28 @@
94
93
  }
95
94
 
96
95
  // header navigation toggle
97
- if (document.querySelectorAll && document.addEventListener){
98
- var els = document.querySelectorAll('.js-header-toggle'),
99
- i, _i;
100
- for(i=0,_i=els.length; i<_i; i++){
101
- els[i].addEventListener('click', function(e){
96
+ if (document.querySelectorAll && document.addEventListener) {
97
+ const els = document.querySelectorAll('.js-header-toggle');
98
+ let i; let _i;
99
+ for(i = 0, _i = els.length; i < _i; i++) {
100
+ els[i].addEventListener('click', function (e) {
102
101
  e.preventDefault();
103
- var target = document.getElementById(this.getAttribute('href').substr(1)),
104
- targetClass = target.getAttribute('class') || '',
105
- sourceClass = this.getAttribute('class') || '';
102
+ const target = document.getElementById(this.getAttribute('href').substr(1));
103
+ const targetClass = target.getAttribute('class') || '';
104
+ const sourceClass = this.getAttribute('class') || '';
106
105
 
107
- if(targetClass.indexOf('js-visible') !== -1){
106
+ if(targetClass.indexOf('js-visible') !== -1) {
108
107
  target.setAttribute('class', targetClass.replace(/(^|\s)js-visible(\s|$)/, ''));
109
108
  } else {
110
- target.setAttribute('class', targetClass + " js-visible");
109
+ target.setAttribute('class', targetClass + ' js-visible');
111
110
  }
112
- if(sourceClass.indexOf('js-visible') !== -1){
111
+ if(sourceClass.indexOf('js-visible') !== -1) {
113
112
  this.setAttribute('class', sourceClass.replace(/(^|\s)js-visible(\s|$)/, ''));
114
113
  } else {
115
- this.setAttribute('class', sourceClass + " js-visible");
114
+ this.setAttribute('class', sourceClass + ' js-visible');
116
115
  }
117
- this.setAttribute('aria-expanded', this.getAttribute('aria-expanded') !== "true");
118
- target.setAttribute('aria-hidden', target.getAttribute('aria-hidden') === "false");
116
+ this.setAttribute('aria-expanded', this.getAttribute('aria-expanded') !== 'true');
117
+ target.setAttribute('aria-hidden', target.getAttribute('aria-hidden') === 'false');
119
118
  });
120
119
  }
121
120
  }
@@ -1,4 +1,4 @@
1
- /* eslint-disable no-var */
1
+ /* eslint-disable no-var, vars-on-top, no-unused-vars */
2
2
  'use strict';
3
3
 
4
4
  var toolkit = require('../../../toolkit');
@@ -8,7 +8,7 @@ var formFocus = toolkit.formFocus;
8
8
  var characterCount = toolkit.characterCount;
9
9
  var validation = toolkit.validation;
10
10
 
11
- var GOVUK = require('govuk-frontend')
11
+ var GOVUK = require('govuk-frontend');
12
12
  GOVUK.initAll();
13
13
  window.GOVUK = GOVUK;
14
14
  var skipToMain = require('./skip-to-main');
@@ -1,18 +1,19 @@
1
1
  const skipToMain = function () {
2
- const skipToMainLink = document.getElementById("skip-to-main");
3
- const firstControlId = skipToMainLink.hash.split('#')[1] ? skipToMainLink.hash.split('#')[1] : "main-content";
4
- if(firstControlId === "main-content"){
5
- skipToMainLink.setAttribute("href", "#main-content");
6
- }
7
- if(firstControlId) {
8
- skipToMainLink.onclick = function(e) {
9
- //here timeout added just to make this functionality asynchronous
10
- //to focus on form as well as non form contents
11
- setTimeout(() => {
12
- const firstControl = document.getElementById(firstControlId);
13
- firstControl.focus();
14
- }, 10);
15
- }
16
- }
17
- };
18
- skipToMain();
2
+ const skipToMainLink = document.getElementById('skip-to-main');
3
+ const firstControlId = skipToMainLink.hash.split('#')[1] ? skipToMainLink.hash.split('#')[1] : 'main-content';
4
+ if(firstControlId === 'main-content') {
5
+ skipToMainLink.setAttribute('href', '#main-content');
6
+ }
7
+ if(firstControlId) {
8
+ // eslint-disable-next-line no-unused-vars
9
+ skipToMainLink.onclick = function (e) {
10
+ // here timeout added just to make this functionality asynchronous
11
+ // to focus on form as well as non form contents
12
+ setTimeout(() => {
13
+ const firstControl = document.getElementById(firstControlId);
14
+ firstControl.focus();
15
+ }, 10);
16
+ };
17
+ }
18
+ };
19
+ skipToMain();
package/index.js CHANGED
@@ -120,8 +120,9 @@ const getContentSecurityPolicy = (config, res) => {
120
120
  * @param options.getTerms {boolean} Optional boolean - whether to mount the /terms endpoint
121
121
  * @param options.getCookies {boolean} Optional boolean - whether to mount the /cookies endpoint
122
122
  * @param options.noCache {boolean} Optional boolean - whether to disable caching
123
- * @param options.getAccessibilityStatement {boolean} Optional boolean - whether to mount the /accessibility-statement endpoint
124
- *
123
+ * @param options.getAccessibilityStatement {boolean} Optional boolean - whether to mount the
124
+ * /accessibility-statement endpoint
125
+ *
125
126
  * @returns {object} A new HOF application using the configuration supplied in options
126
127
  */
127
128
  function bootstrap(options) {
@@ -205,9 +206,13 @@ function bootstrap(options) {
205
206
  }));
206
207
  app.use(mixins());
207
208
  app.use(markdown(config.markdown));
208
-
209
+ // rate limits have to be loaded before all routes so it is applied to them
210
+ if (config.rateLimits.requests.active) {
211
+ app.use(hofMiddleware.rateLimiter(config, 'requests'));
212
+ }
213
+
209
214
  // Set up routing so <YOUR-SITE-URL>/assets are served from /node_modules/govuk-frontend/govuk/assets
210
- app.use('/assets', express.static(path.join(__dirname, '/node_modules/govuk-frontend/govuk/assets')))
215
+ app.use('/assets', express.static(path.join(__dirname, '/node_modules/govuk-frontend/govuk/assets')));
211
216
 
212
217
  if (config.getAccessibility === true) {
213
218
  deprecate(
package/lib/router.js CHANGED
@@ -19,7 +19,8 @@ function getWizardConfig(config) {
19
19
  const wizardConfig = {
20
20
  name: config.route.name || (config.route.baseUrl || '').replace('/', ''),
21
21
  protocol: config.protocol,
22
- env: config.env
22
+ env: config.env,
23
+ sanitiseInputs: config.sanitiseInputs
23
24
  };
24
25
 
25
26
  if (config.appConfig) {
package/lib/settings.js CHANGED
@@ -7,19 +7,20 @@ const hoganExpressStrict = require('hogan-express-strict');
7
7
  const expressPartialTemplates = require('express-partial-templates');
8
8
  const bodyParser = require('body-parser');
9
9
 
10
- const filterEmptyViews = (views) => {
11
- return views.filter(view => dirExists(view))
12
- }
13
-
14
- const dirExists = (dir) => {
10
+ const dirExists = dir => {
15
11
  try {
16
12
  if (fs.existsSync(dir)) {
17
13
  return true;
18
14
  }
15
+ return false;
19
16
  } catch(err) {
20
- throw new Error(`${err}: Cannot check if the directory path exists`)
17
+ throw new Error(`${err}: Cannot check if the directory path exists`);
21
18
  }
22
- }
19
+ };
20
+
21
+ const filterEmptyViews = views => {
22
+ return views.filter(view => dirExists(view));
23
+ };
23
24
 
24
25
  module.exports = async (app, config) => {
25
26
  const viewEngine = config.viewEngine || 'html';
@@ -31,7 +32,7 @@ module.exports = async (app, config) => {
31
32
 
32
33
  app.use(config.theme());
33
34
 
34
- const filteredViews = filterEmptyViews(config.theme.views)
35
+ const filteredViews = filterEmptyViews(config.theme.views);
35
36
  const viewPaths = [].concat(filteredViews);
36
37
  app.set('view engine', viewEngine);
37
38
  app.enable('view cache');
@@ -1,6 +1,8 @@
1
1
  /* eslint-disable no-unused-vars */
2
2
  'use strict';
3
3
 
4
+ const rateLimitsConfig = require('../config/rate-limits');
5
+
4
6
  const errorTitle = code => `${code}_ERROR`;
5
7
  const errorMsg = code => `There is a ${code}_ERROR`;
6
8
  // eslint-disable-next-line complexity
@@ -21,6 +23,36 @@ const getContent = (err, translate) => {
21
23
  content.message = (translate && translate('errors.cookies-required.message'));
22
24
  }
23
25
 
26
+ if (err.code === 'DDOS_RATE_LIMIT') {
27
+ err.status = 429;
28
+ err.template = 'rate-limit-error';
29
+ err.title = (translate && translate('errors.ddos-rate-limit.title'));
30
+ err.message = (translate && translate('errors.ddos-rate-limit.message'));
31
+ err.preTimeToWait = (translate && translate('errors.ddos-rate-limit.pre-time-to-wait'));
32
+ err.timeToWait = rateLimitsConfig.rateLimits.requests.windowSizeInMinutes;
33
+ err.postTimeToWait = (translate && translate('errors.ddos-rate-limit.post-time-to-wait'));
34
+ content.title = (translate && translate('errors.ddos-rate-limit.title'));
35
+ content.message = (translate && translate('errors.ddos-rate-limit.message'));
36
+ content.preTimeToWait = (translate && translate('errors.ddos-rate-limit.pre-time-to-wait'));
37
+ content.timeToWait = rateLimitsConfig.rateLimits.requests.windowSizeInMinutes;
38
+ content.postTimeToWait = (translate && translate('errors.ddos-rate-limit.post-time-to-wait'));
39
+ }
40
+
41
+ if (err.code === 'SUBMISSION_RATE_LIMIT') {
42
+ err.status = 429;
43
+ err.template = 'rate-limit-error';
44
+ err.title = (translate && translate('errors.submission-rate-limit.title'));
45
+ err.message = (translate && translate('errors.submission-rate-limit.message'));
46
+ err.preTimeToWait = (translate && translate('errors.submission-rate-limit.pre-time-to-wait'));
47
+ err.timeToWait = rateLimitsConfig.rateLimits.submissions.windowSizeInMinutes;
48
+ err.postTimeToWait = (translate && translate('errors.submission-rate-limit.post-time-to-wait'));
49
+ content.title = (translate && translate('errors.submission-rate-limit.title'));
50
+ content.message = (translate && translate('errors.submission-rate-limit.message'));
51
+ content.preTimeToWait = (translate && translate('errors.submission-rate-limit.pre-time-to-wait'));
52
+ content.timeToWait = rateLimitsConfig.rateLimits.submissions.windowSizeInMinutes;
53
+ content.postTimeToWait = (translate && translate('errors.submission-rate-limit.post-time-to-wait'));
54
+ }
55
+
24
56
  err.code = err.code || 'UNKNOWN';
25
57
  err.status = err.status || 500;
26
58
 
@@ -4,5 +4,6 @@ module.exports = {
4
4
  cookies: require('./cookies'),
5
5
  errors: require('./errors'),
6
6
  notFound: require('./not-found'),
7
- deepTranslate: require('./deep-translate')
7
+ deepTranslate: require('./deep-translate'),
8
+ rateLimiter: require('./rate-limiter')
8
9
  };
@@ -0,0 +1,98 @@
1
+
2
+ const moment = require('moment');
3
+ const redis = require('redis');
4
+ const config = require('./../config/hof-defaults');
5
+
6
+ module.exports = (options, rateLimitType) => {
7
+ // eslint-disable-next-line no-console
8
+ const logger = options.logger || { log: (func, msg) => console[func](msg) };
9
+ const rateLimits = options.rateLimits[rateLimitType];
10
+ const timestampName = `${rateLimitType}TimeStamp`;
11
+ const countName = `${rateLimitType}Count`;
12
+
13
+ const WINDOW_SIZE_IN_MINUTES = rateLimits.windowSizeInMinutes;
14
+ const MAX_WINDOW_REQUEST_COUNT = rateLimits.maxWindowRequestCount;
15
+ const WINDOW_LOG_INTERVAL_IN_MINUTES = rateLimits.windowLogIntervalInMinutes;
16
+ const ERROR_CODE = rateLimits.errCode;
17
+
18
+ return async (req, res, next) => {
19
+ const redisClient = redis.createClient(config.redis);
20
+
21
+ // check that redis client exists
22
+ if (!redisClient) {
23
+ logger.log('error', 'Redis client does not exist!');
24
+ return next();
25
+ }
26
+
27
+ const closeConnection = async err => {
28
+ await redisClient.quit();
29
+ return next(err);
30
+ };
31
+
32
+ try {
33
+ // fetch records of current user using IP address, returns null when no record is found
34
+ return await redisClient.get(req.ip, async (err, record) => {
35
+ if (err) {
36
+ logger.log('error', `Error with requesting redis session for rate limiting: ${err}`);
37
+ return await closeConnection();
38
+ }
39
+ const currentRequestTime = moment();
40
+ const windowStartTimestamp = moment().subtract(WINDOW_SIZE_IN_MINUTES, 'minutes').unix();
41
+ let oldRecord = false;
42
+ let data;
43
+ // if no record is found , create a new record for user and store to redis
44
+ if (record) {
45
+ data = JSON.parse(record);
46
+ oldRecord = data[data.length - 1][timestampName] < windowStartTimestamp;
47
+ }
48
+
49
+ if (!record || oldRecord) {
50
+ const newRecord = [];
51
+ const requestLog = {
52
+ [timestampName]: currentRequestTime.unix(),
53
+ [countName]: 1
54
+ };
55
+ newRecord.push(requestLog);
56
+ await redisClient.set(req.ip, JSON.stringify(newRecord));
57
+ return await closeConnection();
58
+ }
59
+ // if record is found, parse it's value and calculate number of requests users has made within the last window
60
+ const requestsWithinWindow = data.filter(entry => entry[timestampName] > windowStartTimestamp);
61
+
62
+ const totalWindowRequestsCount = requestsWithinWindow.reduce((accumulator, entry) => {
63
+ return accumulator + entry[countName];
64
+ }, 0);
65
+
66
+ if (!options.rateLimits.env || options.rateLimits.env === 'development') {
67
+ const requestsRemaining = MAX_WINDOW_REQUEST_COUNT - totalWindowRequestsCount;
68
+ const msg = `Requests made by client: ${totalWindowRequestsCount}\nRequests remaining: ${requestsRemaining}`;
69
+ logger.log('info', msg);
70
+ }
71
+ // if number of requests made is greater than or equal to the desired maximum, return error
72
+ if (totalWindowRequestsCount >= MAX_WINDOW_REQUEST_COUNT) {
73
+ return await closeConnection({ code: ERROR_CODE });
74
+ }
75
+ // if number of requests made is less than allowed maximum, log new entry
76
+ const lastRequestLog = data[data.length - 1];
77
+ const potentialCurrentWindowIntervalStartTimeStamp = currentRequestTime
78
+ .subtract(WINDOW_LOG_INTERVAL_IN_MINUTES, 'minutes')
79
+ .unix();
80
+ // if interval has not passed since last request log, increment counter
81
+ if (lastRequestLog[timestampName] > potentialCurrentWindowIntervalStartTimeStamp) {
82
+ lastRequestLog[countName]++;
83
+ data[data.length - 1] = lastRequestLog;
84
+ } else {
85
+ // if interval has passed, log new entry for current user and timestamp
86
+ data.push({
87
+ [timestampName]: currentRequestTime.unix(),
88
+ [countName]: 1
89
+ });
90
+ }
91
+ await redisClient.set(req.ip, JSON.stringify(data));
92
+ return await closeConnection();
93
+ });
94
+ } catch (err) {
95
+ return await closeConnection(err);
96
+ }
97
+ };
98
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hof",
3
3
  "description": "A bootstrap for HOF projects",
4
- "version": "20.0.0-beta.6",
4
+ "version": "20.0.0-beta.9",
5
5
  "license": "MIT",
6
6
  "main": "index.js",
7
7
  "author": "HomeOffice",
@@ -57,7 +57,7 @@
57
57
  "findup": "^0.1.5",
58
58
  "glob": "^7.2.0",
59
59
  "govuk-elements-sass": "^3.1.3",
60
- "govuk-frontend": "^4.1.0",
60
+ "govuk-frontend": "3.14",
61
61
  "govuk_template_mustache": "^0.26.0",
62
62
  "helmet": "^3.22.0",
63
63
  "hogan-express-strict": "^0.5.4",
@@ -70,10 +70,9 @@
70
70
  "lodash": "^4.17.21",
71
71
  "markdown-it": "^12.3.2",
72
72
  "minimatch": "^3.0.3",
73
- "minimist": "^1.2.0",
73
+ "minimist": "^1.2.6",
74
74
  "mixwith": "^0.1.1",
75
- "mkdirp": "^0.5.1",
76
- "moment": "^2.24.0",
75
+ "moment": "^2.29.2",
77
76
  "morgan": "^1.10.0",
78
77
  "mustache": "^2.3.0",
79
78
  "nodemailer": "^6.6.3",
@@ -88,8 +87,8 @@
88
87
  "serve-static": "^1.14.1",
89
88
  "uglify-js": "^3.14.3",
90
89
  "underscore": "^1.12.1",
91
- "urijs": "^1.19.10",
92
- "winston": "^3.3.3"
90
+ "urijs": "^1.19.11",
91
+ "winston": "^3.7.2"
93
92
  },
94
93
  "devDependencies": {
95
94
  "@cucumber/cucumber": "^7.3.0",