Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -1437,14 +1437,31 @@ 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.
```js
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:

Expand Down
11 changes: 8 additions & 3 deletions frontend/themes/gov-uk/client-js/session-timeout-dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,14 @@
},

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.');

Check warning on line 286 in frontend/themes/gov-uk/client-js/session-timeout-dialog.js

View workflow job for this annotation

GitHub Actions / test (22.x, 8)

Unexpected console statement

Check warning on line 286 in frontend/themes/gov-uk/client-js/session-timeout-dialog.js

View workflow job for this annotation

GitHub Actions / test (24.x, 8)

Unexpected console statement

Check warning on line 286 in frontend/themes/gov-uk/client-js/session-timeout-dialog.js

View workflow job for this annotation

GitHub Actions / test (20.x, 7)

Unexpected console statement

Check warning on line 286 in frontend/themes/gov-uk/client-js/session-timeout-dialog.js

View workflow job for this annotation

GitHub Actions / test (22.x, 7)

Unexpected console statement

Check warning on line 286 in frontend/themes/gov-uk/client-js/session-timeout-dialog.js

View workflow job for this annotation

GitHub Actions / test (24.x, 7)

Unexpected console statement

Check warning on line 286 in frontend/themes/gov-uk/client-js/session-timeout-dialog.js

View workflow job for this annotation

GitHub Actions / test (20.x, 8)

Unexpected console statement
});
},

redirect: function () {
Expand Down
4 changes: 2 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'"]
};
Expand All @@ -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'
Expand All @@ -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) {
Expand Down
53 changes: 51 additions & 2 deletions test/frontend/jest/sessionDialog.test.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
/* 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');

jest.dontMock('fs');

describe('sessionDialog', () => {
let $;
let sessionDialog;
let $body;
let $html;
Expand All @@ -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 =
Expand Down Expand Up @@ -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();
});
});
26 changes: 26 additions & 0 deletions test/integration/server.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});

Expand Down
Loading