diff --git a/CHANGELOG.md b/CHANGELOG.md index fa0b23a1..f082e7c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## 2026-03-25, Version 23.0.4 (Stable), @PaolaDMadd-Pro + +### Fixed +- Decoupled session timeout keep alive from analytics configuration. +- Updated CSP defaults to always include `connect-src 'self'` so same-origin keep alive requests are allowed. +- Updated GA CSP behavior to **extend** `connect-src` with analytics endpoints when `gaTagId` is set, rather than replacing defaults. + +### Changed +- Improved timeout dialog refresh behavior: + * On keep alive success, `timeSessionRefreshed` is updated and timeout controller logic is restarted. + * On keep alive failure a console.error is triggered. + +### Tests +- Added integration regression coverage for CSP: + * `gaTagId` set: GA and region analytics `connect-src` endpoints are present. + * `gaTagId` not set: default `connect-src 'self'` remains and GA region endpoints are absent. +- Added frontend Jest coverage for timeout refresh behavior: + * Success path (`$.get().done`) updates refresh time and calls controller. + * Failure path (`$.get().fail`) triggers a console.error and does not call controller. + + ## 2026-03-18, Version 23.0.3 (Stable), @vinodhasamiyappan-ho ### Security diff --git a/README.md b/README.md index 30277ddc..c27e57c9 100644 --- a/README.md +++ b/README.md @@ -1407,8 +1407,8 @@ This feature allows you to customise the content related to the session timeout ### Usage -By default, the session timeout is set to the redis session ttl. To bypass this and display the session timeout message before the redis session ttl the following evironment variables must be set: -`CUSTOM_SESSION_EXPIRY` - e.g. `600`. Configure to expire before thte project's redis session ttl. +By default, the session timeout is set to the redis session ttl. To bypass this and display the session timeout message before the redis session ttl the following environment variables must be set: +`CUSTOM_SESSION_EXPIRY` - e.g. `600`. Configure to expire before the project's redis session ttl. `USE_CUSTOM_SESSION_TIMEOUT` - `false` by default. When set to `true` the '/session-timeout' page can run before the session expires without triggering a `404` middleware error. To enable and customise the session timeout behaviour, you need to set the component and translations in your project's `hof.settings.json` file: @@ -1437,7 +1437,7 @@ To override the default session-timeout page completely, the path to the session "hof/components/session-timeout-warning" ], "translations": "./apps/common/translations", - "views": ["./apps/common/views"], // allows you to overide the HOF default session-timeout page and use a custom one from the specified views + "views": ["./apps/common/views"], // allows you to override the HOF default session-timeout page and use a custom one from the specified views ... ``` or in the project's `server.js` e.g. @@ -1445,6 +1445,23 @@ or in the project's `server.js` e.g. settings.views = path.resolve(__dirname, './apps/common/views'); ``` + +### Session Timeout Keep-alive and CSP + +From version 23.0.4 , session timeout keep alive is now independent of analytics tags. + +- Default CSP always includes: `connect-src 'self'` +- If `GA_TAG` (`gaTagId`) is configured: + - Google Analytics endpoints are added to `connect-src`. +- If `GA_TAG` is not configured: Only default same-origin `connect-src` is used (no GA region endpoints). + +### Timeout Dialog Behavior +When a user clicks **Stay on this page** in the timeout dialog: + +- If keep-alive succeeds: session refresh timestamp is updated and the timeout countdown/controller is restarted. +- If keep-alive fails: a ```console.error``` is flagged by default. + + ### Customising content in `pages.json` Once the variables are set, you can customise the session timeout warning and exit messages in your project's pages.json: diff --git a/frontend/themes/gov-uk/client-js/session-timeout-dialog.js b/frontend/themes/gov-uk/client-js/session-timeout-dialog.js index 135205fb..389c1a5d 100644 --- a/frontend/themes/gov-uk/client-js/session-timeout-dialog.js +++ b/frontend/themes/gov-uk/client-js/session-timeout-dialog.js @@ -277,9 +277,14 @@ window.GOVUK.sessionDialog = { }, refreshSession: function () { - $.get(''); - window.GOVUK.sessionDialog.timeSessionRefreshed = new Date(); - window.GOVUK.sessionDialog.controller(); + $.get('') + .done(function () { + window.GOVUK.sessionDialog.timeSessionRefreshed = new Date(); + window.GOVUK.sessionDialog.controller(); + }) + .fail(function () { + console.error('Session refresh failed.'); + }); }, redirect: function () { diff --git a/index.js b/index.js index 5f47e3f8..0360b2ad 100644 --- a/index.js +++ b/index.js @@ -77,6 +77,7 @@ const getContentSecurityPolicy = (config, res) => { imgSrc: ["'self'"], fontSrc: ["'self'", 'data:', 'https://design-system.service.gov.uk'], scriptSrc: ["'self'", `'nonce-${res.locals.nonce}'`], + connectSrc: ["'self'"], 'frame-ancestors': ["'none'"], manifestSrc: ["'self'"] }; @@ -92,7 +93,6 @@ const getContentSecurityPolicy = (config, res) => { 'www.google.co.uk/ads/ga-audiences' ], connectSrc: [ - "'self'", 'https://www.google-analytics.com', 'https://region1.google-analytics.com', 'https://region1.analytics.google.com' @@ -105,7 +105,7 @@ const getContentSecurityPolicy = (config, res) => { directives.scriptSrc = directives.scriptSrc.concat(gaDirectives.scriptSrc); directives.fontSrc = directives.fontSrc.concat(gaDirectives.fontSrc); directives.imgSrc = directives.imgSrc.concat(gaDirectives.imgSrc); - directives.connectSrc = gaDirectives.connectSrc; + directives.connectSrc = directives.connectSrc.concat(gaDirectives.connectSrc); } if (csp && !csp.disabled) { diff --git a/test/frontend/jest/sessionDialog.test.js b/test/frontend/jest/sessionDialog.test.js index 4aecbf72..7fb74817 100644 --- a/test/frontend/jest/sessionDialog.test.js +++ b/test/frontend/jest/sessionDialog.test.js @@ -1,7 +1,5 @@ /* eslint-disable max-len, quotes */ 'use strict'; - -const $ = require('jquery'); const fs = require('fs'); const path = require('path'); const sessionTimeoutWarningHtml = fs.readFileSync(path.resolve(__dirname, '../../../frontend/template-partials/views/partials/session-timeout-warning.html'), 'utf8'); @@ -9,6 +7,7 @@ const sessionTimeoutWarningHtml = fs.readFileSync(path.resolve(__dirname, '../.. jest.dontMock('fs'); describe('sessionDialog', () => { + let $; let sessionDialog; let $body; let $html; @@ -23,6 +22,7 @@ describe('sessionDialog', () => { beforeEach(() => { jest.resetModules(); window.GOVUK = {}; + $ = require('jquery'); // Set up the initial DOM structure and jQuery elements for each test document.body.innerHTML = @@ -252,4 +252,53 @@ describe('sessionDialog', () => { expect(controller).not.toHaveBeenCalled(); expect(result).toBe(false); }); + + it('refreshSession updates timeSessionRefreshed and calls controller on success', () => { + jest.useFakeTimers(); + const previousRefreshTime = new Date('2020-01-01T00:00:00.000Z'); + const now = new Date(previousRefreshTime.getTime() + (10 * 60 * 1000)); + jest.setSystemTime(now); + + sessionDialog.timeSessionRefreshed = previousRefreshTime; + const controllerSpy = jest.spyOn(sessionDialog, 'controller').mockImplementation(() => {}); + const requestMock = { + done: jest.fn(), + fail: jest.fn() + }; + + requestMock.done.mockImplementation(cb => { + cb(); + return requestMock; + }); + + requestMock.fail.mockReturnValue(requestMock); + + jest.spyOn($, 'get').mockReturnValue(requestMock); + + sessionDialog.refreshSession(); + expect(sessionDialog.timeSessionRefreshed.getTime()).toBe(now.getTime()); + expect(controllerSpy).toHaveBeenCalledTimes(1); + }); + + it('refreshSession logs an error on failure and does not call controller', () => { + const controllerSpy = jest.spyOn(sessionDialog, 'controller').mockImplementation(() => {}); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const requestMock = { + done: jest.fn(), + fail: jest.fn() + }; + + requestMock.done.mockReturnValue(requestMock); + requestMock.fail.mockImplementation(cb => { + cb(); + return requestMock; + }); + + jest.spyOn($, 'get').mockReturnValue(requestMock); + + sessionDialog.refreshSession(); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Session refresh failed.'); + expect(controllerSpy).not.toHaveBeenCalled(); + }); }); diff --git a/test/integration/server.spec.js b/test/integration/server.spec.js index d81cf1b0..9e04f3bf 100644 --- a/test/integration/server.spec.js +++ b/test/integration/server.spec.js @@ -497,6 +497,32 @@ describe('hof server', () => { const csp = getHeaders(res, 'content-security-policy'); csp['img-src'].should.include('www.google-analytics.com'); csp['script-src'].should.include('www.google-analytics.com'); + csp['connect-src'].should.include('https://region1.google-analytics.com'); + csp['connect-src'].should.include('https://region1.analytics.google.com'); + }); + }); + it('CSP keeps default directives and excludes google directives if gaTagId not set', () => { + const bs = bootstrap({ + csp: {}, + fields: 'fields', + gaTagId: '', + routes: [{ + views: `${root}/apps/app_1/views`, + steps: { + '/one': {} + } + }] + }); + return request(bs.server) + .get('/one') + .set('Cookie', ['myCookie=1234']) + .expect(res => { + const csp = getHeaders(res, 'content-security-policy'); + csp['connect-src'].should.include("'self'"); + csp['connect-src'].should.not.include('https://region1.google-analytics.com'); + csp['connect-src'].should.not.include('https://region1.analytics.google.com'); + csp['img-src'].should.not.include('www.google-analytics.com'); + csp['script-src'].should.not.include('www.google-analytics.com'); }); });