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
3 changes: 2 additions & 1 deletion app/oauth2/oauth2-route.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ const oauth2Route = (req, res, next) => {
{
maxAge: jsonResult.expires_in * 1000,
httpOnly: true,
secure: config.get('security.secure_auth_cookie_enabled')
secure: config.get('security.secure_auth_cookie_enabled'),
sameSite: 'Lax'
});
res.status(204).send();
}
Expand Down
92 changes: 58 additions & 34 deletions app/security/cors.js
Original file line number Diff line number Diff line change
@@ -1,47 +1,71 @@
const config = require('config');
const sanitize = require('../util/sanitize');

const WILDCARD = '*';

const createWhitelistValidator = (val) => {
const whitelist = config.get('security.cors_origin_whitelist').split(',');
for (let w of whitelist) {
if (val === w || WILDCARD === w) {
return true;
}
const escapeRegex = (str) =>
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

const ALLOWED_HEADERS = ['content-type', 'authorization'];

const isOriginAllowed = (origin) => {
if (typeof origin !== 'string') return false;

const whitelist = config
.get('security.cors_origin_whitelist')
.split(',')
.map(w => w.trim());

if (whitelist.includes('*')) {
throw new Error('CORS whitelist cannot contain "*"');
}

return whitelist.some(w => {
if (w === origin) return true;

if (w.includes('*')) {
const pattern =
'^' +
escapeRegex(w).replace(/\\\*/g, '[^.]+') +
'$';

return new RegExp(pattern).test(origin);
}

return false;
});
};

const corsOptions = {
allowOrigin: createWhitelistValidator,
allowCredentials: true,
allowMethods: config.get('security.cors_origin_methods')
const resolveAllowedHeaders = (req) => {
const requested = req.get('Access-Control-Request-Headers');

if (!requested) return 'Content-Type, Authorization';

const filtered = requested
.split(',')
.map(h => h.trim())
.filter(h => ALLOWED_HEADERS.includes(h.toLowerCase()))
.join(', ');

return filtered || 'Content-Type, Authorization';
};

const handleCors = (req, res, next) => {
if (corsOptions.allowOrigin) {
const origin = req.get('origin');
if (corsOptions.allowOrigin(origin)) {
res.set('Access-Control-Allow-Origin', sanitize.sanitizeData(origin));
}
} else {
res.set('Access-Control-Allow-Origin', '*');
}
if (corsOptions.allowCredentials) {
res.set('Access-Control-Allow-Credentials', corsOptions.allowCredentials);
}
if (corsOptions.allowMethods) {
res.set('Access-Control-Allow-Methods', corsOptions.allowMethods);
}
res.set('Access-Control-Allow-Headers', sanitize.sanitizeData(req.get('Access-Control-Request-Headers')));
if('OPTIONS' === req.method) {
res
.status(200)
.end();
} else {
next();
}
const origin = req.get('origin');

if (!origin || !isOriginAllowed(origin)) {
return res.status(403).end();
}

res.set('Access-Control-Allow-Origin', sanitize.sanitizeData(origin));
res.set('Access-Control-Allow-Credentials', true);
res.set('Access-Control-Allow-Methods', config.get('security.cors_origin_methods'));
res.set('Access-Control-Allow-Headers', resolveAllowedHeaders(req));
res.set('Vary', 'Origin');

if (req.method === 'OPTIONS') {
return res.status(200).end();
}

next();
};

module.exports = handleCors;
2 changes: 1 addition & 1 deletion charts/ccd-api-gateway-web/values.aat.template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ nodejs:
image: ${IMAGE_NAME}
ingressHost: ${SERVICE_FQDN}
environment:
CORS_ORIGIN_WHITELIST: "*"
CORS_ORIGIN_WHITELIST: "https://*.preview.platform.hmcts.net"
2 changes: 1 addition & 1 deletion charts/ccd-api-gateway-web/values.preview.template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ nodejs:
IDAM_OAUTH2_TOKEN_ENDPOINT: https://idam-api.aat.platform.hmcts.net/oauth2/token
IDAM_OAUTH2_LOGOUT_ENDPOINT: https://idam-api.aat.platform.hmcts.net/session/:token
IDAM_BASE_URL: https://idam-api.aat.platform.hmcts.net
CORS_ORIGIN_WHITELIST: "*"
CORS_ORIGIN_WHITELIST: "https://*.preview.platform.hmcts.net"
TIMING-ALLOW-ORIGIN: "*"
PROXY_MV_ANNOTATIONS_API_URL: https://em-anno-aat.service.core-compute-aat.internal

Expand Down
102 changes: 64 additions & 38 deletions test/oauth2/oauth2-route.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ const proxyquire = require('proxyquire');
const sinon = require('sinon');
const sinonChai = require('sinon-chai');
const sinonExpressMock = require('sinon-express-mock');
const ACCESS_TOKEN_COOKIE_NAME = require('../../app/oauth2/oauth2-route').COOKIE_ACCESS_TOKEN;

const ACCESS_TOKEN_COOKIE_NAME =
require('../../app/oauth2/oauth2-route').COOKIE_ACCESS_TOKEN;

chai.use(sinonChai);

describe('oauth2Route', () => {

const TOKEN = {
access_token: 'ey123.ey456',
expires_in: 3600
Expand All @@ -22,96 +26,118 @@ describe('oauth2Route', () => {
let responseFromPromiseMock;

beforeEach(() => {

config = {
get: sinon.stub()
};

request = sinonExpressMock.mockReq();
response = sinonExpressMock.mockRes();
next = sinon.stub();

responseFromPromiseMock = {
status: 200,
json: sinon.stub()
};

request = sinonExpressMock.mockReq();
response = sinonExpressMock.mockRes();
next = sinon.stub();
accessTokenRequest = sinon.stub();
accessTokenRequest.withArgs(request).returns(Promise.resolve(responseFromPromiseMock));
accessTokenRequest.withArgs(request)
.returns(Promise.resolve(responseFromPromiseMock));

oauth2Route = proxyquire('../../app/oauth2/oauth2-route', {
'./access-token-request': accessTokenRequest,
'config': config
config
}).oauth2Route;
});

it('should set an accessToken cookie with the "secure" flag enabled', done => {
it('should set accessToken cookie with secure flag enabled', (done) => {

config.get.withArgs('security.secure_auth_cookie_enabled').returns(true);
responseFromPromiseMock.json.withArgs().returns(Promise.resolve(TOKEN));
responseFromPromiseMock.json.returns(Promise.resolve(TOKEN));

response.send.callsFake( () => {
response.status.callsFake(() => response);
response.send.callsFake(() => {
try {
expect(accessTokenRequest).to.have.been.calledWith(request);
expect(config.get).to.have.been.calledWith('security.secure_auth_cookie_enabled');

expect(response.cookie).to.have.been.calledWith(
ACCESS_TOKEN_COOKIE_NAME,
TOKEN.access_token,
{
maxAge: TOKEN.expires_in * 1000,
httpOnly: true,
secure: true,
sameSite: 'Lax'
}
);

expect(response.status).to.have.been.calledWith(204);

expect(accessTokenRequest).to.be.calledWith(request);
expect(config.get).to.be.calledWith('security.secure_auth_cookie_enabled');
expect(response.cookie).to.be.calledWith(ACCESS_TOKEN_COOKIE_NAME, TOKEN.access_token,
{ maxAge: TOKEN.expires_in * 1000, httpOnly: true, secure: true });
expect(response.status).to.be.calledWith(204);
done();
} catch (e) {
done(e);
} catch (err) {
done(err);
}
});

oauth2Route(request, response, next);
});

it('should set an accessToken cookie with the "secure" flag disabled', done => {
it('should set accessToken cookie with secure flag disabled', (done) => {

config.get.withArgs('security.secure_auth_cookie_enabled').returns(false);
responseFromPromiseMock.json.withArgs().returns(Promise.resolve(TOKEN));
responseFromPromiseMock.json.returns(Promise.resolve(TOKEN));

response.status.callsFake(() => response);
response.send.callsFake(() => {
try {
expect(accessTokenRequest).to.be.calledWith(request);
expect(config.get).to.be.calledWith('security.secure_auth_cookie_enabled');
expect(response.cookie).to.be.calledWith(ACCESS_TOKEN_COOKIE_NAME, TOKEN.access_token,
{ maxAge: TOKEN.expires_in * 1000, httpOnly: true, secure: false });
expect(response.status).to.be.calledWith(204);
expect(response.cookie).to.have.been.calledWith(
ACCESS_TOKEN_COOKIE_NAME,
TOKEN.access_token,
{
maxAge: TOKEN.expires_in * 1000,
httpOnly: true,
secure: false,
sameSite: 'Lax'
}
);

expect(response.status).to.have.been.calledWith(204);
done();
} catch (e) {
done(e);
} catch (err) {
done(err);
}
});

oauth2Route(request, response, next);
});

it('should fail to obation an accessToken dude to unauthorized request.', done => {
it('should call next with error when token request fails', (done) => {

let expectedError = {
const expectedError = {
status: 502,
message: 'Internal Server Error'
};

let unauthorizedAccessTokenRequest = sinon.stub();
unauthorizedAccessTokenRequest.withArgs(request).returns(Promise.resolve(expectedError));
const failingRequest = sinon.stub()
.withArgs(request)
.returns(Promise.resolve(expectedError));

let unauthorizedOauth2Route = proxyquire('../../app/oauth2/oauth2-route', {
'./access-token-request': unauthorizedAccessTokenRequest,
'config': config
const failingRoute = proxyquire('../../app/oauth2/oauth2-route', {
'./access-token-request': failingRequest,
config
}).oauth2Route;

next.callsFake((result) => {
next.callsFake((err) => {
try {

expect(unauthorizedAccessTokenRequest).to.be.calledWith(request);
expect(result).to.eql(expectedError);
expect(failingRequest).to.have.been.calledWith(request);
expect(err).to.eql(expectedError);
done();
} catch (e) {
done(e);
}
});

unauthorizedOauth2Route(request, response, next);
failingRoute(request, response, next);
});

});
Loading