@lion/ajax: only add XSRF token on mutable requests and on same origin or whitelisted origins

This commit is contained in:
Henk vd Brink 2024-02-29 15:05:58 +01:00 committed by GitHub
parent eb46728826
commit 04d08683f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 122 additions and 11 deletions

View file

@ -0,0 +1,12 @@
---
'@lion/ajax': major
---
BREAKING: Only add XSRF token on mutable requests and on same origin or whitelisted origins
Previously the XSRF token was added to any call to any origin.
This is changed in two ways.
(1) The token is now only attached to requests that are POST/PUT/PATCH/DELETE.
(2) It will validate if the request origin is the same as current origin or when the origin is in the xsrfTrustedOrigins.
This is a fix for a vulnerability: we inadvertently revealed the confidential XSRF-TOKEN stored in cookies by including it in the HTTP header X-XSRF-TOKEN for every request made to any host. This allowed attackers to view sensitive information.

View file

@ -50,6 +50,7 @@ export class Ajax {
addCaching: false,
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN',
xsrfTrustedOrigins: [],
jsonPrefix: '',
...config,
cacheOptions: {
@ -67,9 +68,11 @@ export class Ajax {
this.addRequestInterceptor(acceptLanguageRequestInterceptor);
}
const { xsrfCookieName, xsrfHeaderName } = this.__config;
if (xsrfCookieName && xsrfHeaderName) {
this.addRequestInterceptor(createXsrfRequestInterceptor(xsrfCookieName, xsrfHeaderName));
const { xsrfCookieName, xsrfHeaderName, xsrfTrustedOrigins } = this.__config;
if (xsrfCookieName && xsrfHeaderName && xsrfTrustedOrigins) {
this.addRequestInterceptor(
createXsrfRequestInterceptor(xsrfCookieName, xsrfHeaderName, xsrfTrustedOrigins),
);
}
// eslint-disable-next-line prefer-destructuring

View file

@ -2,6 +2,58 @@
* @typedef {import('../../types/types.js').RequestInterceptor} RequestInterceptor
*/
/**
* Parse a URL to discover its components
*
* @param {String | {protocol: string, host: string }} url The URL to be parsed
* @returns {{protocol: string, host: string }}
*/
function resolveURL(url) {
if (typeof url !== 'string') {
return url;
}
const href = url;
const urlParsingNode = document.createElement('a');
urlParsingNode.setAttribute('href', href);
// urlParsingNode provides the UrlUtils interface - http://url.spec.whatwg.org/#urlutils
return {
protocol: urlParsingNode.protocol ? urlParsingNode.protocol.replace(/:$/, '') : '',
host: urlParsingNode.host,
};
}
/**
* Determine if two URLs share the same origin.
*
* @param {string | {protocol: string, host: string }} url1 - First URL to compare as a string or a normalized URL
* @param {string | {protocol: string, host: string }} url2 - Second URL to compare as a string or a normalized URL
*
* @returns {boolean} - true if both URLs have the same origin, and false otherwise.
*/
function urlsAreSameOrigin(url1, url2) {
const parsedUrl1 = resolveURL(url1);
const parsedUrl2 = resolveURL(url2);
return parsedUrl1.protocol === parsedUrl2.protocol && parsedUrl1.host === parsedUrl2.host;
}
/**
* Check if the URL provided has the same origin as the one used.
* Or if it is for a trusted XSRF origin.
*
* @param {String} requestURL The requestedURL
* @param {string[]} xsrfTrustedOrigins List of allowed origins
* @returns {boolean} true if it is the same origin, else false
*/
function isURLTrustedOrSameOrigin(requestURL, xsrfTrustedOrigins) {
const parsed = typeof requestURL === 'string' ? resolveURL(requestURL) : requestURL;
const originURL = resolveURL(window.location.href);
const parsedAllowedOriginUrls = [originURL].concat(xsrfTrustedOrigins.map(resolveURL));
return parsedAllowedOriginUrls.some(urlsAreSameOrigin.bind(null, parsed));
}
/**
* @param {string} name the cookie name
* @param {Document | { cookie: string }} _document overwriteable for testing
@ -17,16 +69,26 @@ export function getCookie(name, _document = document) {
* against cross-site request forgery.
* @param {string} cookieName the cookie name
* @param {string} headerName the header name
* @param {string[]} xsrfTrustedOrigins List of trusted origins
* @param {Document | { cookie: string }} _document overwriteable for testing
* @returns {RequestInterceptor}
*/
export function createXsrfRequestInterceptor(cookieName, headerName, _document = document) {
export function createXsrfRequestInterceptor(
cookieName,
headerName,
xsrfTrustedOrigins,
_document = document,
) {
/**
* @param {Request} request
*/
async function xsrfRequestInterceptor(request) {
const xsrfToken = getCookie(cookieName, _document);
if (xsrfToken) {
const isSameSite = isURLTrustedOrSameOrigin(request.url, xsrfTrustedOrigins);
// Only add the XSRF token when it is needed and/or allowed.
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method) && isSameSite && xsrfToken) {
request.headers.set(headerName, xsrfToken);
}
return request;

View file

@ -46,6 +46,7 @@ describe('Ajax', () => {
addCaching: false,
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN',
xsrfTrustedOrigins: [],
jsonPrefix: ")]}',",
cacheOptions: {
useCache: true,
@ -386,7 +387,7 @@ describe('Ajax', () => {
});
it('XSRF token header is set based on cookie', async () => {
await ajax.fetch('/foo');
await ajax.fetch('/foo', { method: 'POST' });
const request = fetchStub.getCall(0).args[0];
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('1234');
@ -394,7 +395,7 @@ describe('Ajax', () => {
it('XSRF behavior can be disabled', async () => {
const customAjax = new Ajax({ xsrfCookieName: null, xsrfHeaderName: null });
await customAjax.fetch('/foo');
await customAjax.fetch('/foo', { method: 'POST' });
await ajax.fetch('/foo');
const request = fetchStub.getCall(0).args[0];
@ -406,11 +407,43 @@ describe('Ajax', () => {
xsrfCookieName: 'CSRF-TOKEN',
xsrfHeaderName: 'X-CSRF-TOKEN',
});
await customAjax.fetch('/foo');
await customAjax.fetch('/foo', { method: 'POST' });
const request = fetchStub.getCall(0).args[0];
expect(request.headers.get('X-CSRF-TOKEN')).to.equal('5678');
});
it('should not set the XSRF header when a non updating method is used', async () => {
await ajax.fetch('/foo', { method: 'GET' });
const request = fetchStub.getCall(0).args[0];
expect(request.headers.get('X-XSRF-TOKEN')).to.be.null;
});
it('should not set the XSRF header if the url is from a different origin', async () => {
await ajax.fetch('https://api.localhost:8000/foo', { method: 'POST' });
const request = fetchStub.getCall(0).args[0];
expect(request.headers.get('X-XSRF-TOKEN')).to.be.null;
});
it('should set the XSRF header if origin is the same', async () => {
await ajax.fetch('/foo', { method: 'POST' });
const request = fetchStub.getCall(0).args[0];
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('1234');
});
it('should set the XSRF header if origin is in the trusted origin list', async () => {
const customAjax = new Ajax({
xsrfTrustedOrigins: ['https://api.localhost'],
});
await customAjax.fetch('https://api.localhost/foo', { method: 'POST' });
const request = fetchStub.getCall(0).args[0];
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('1234');
});
});
describe('Caching', () => {

View file

@ -23,16 +23,16 @@ describe('getCookie()', () => {
describe('createXsrfRequestInterceptor()', () => {
it('adds the xsrf token header to the request', () => {
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', [], {
cookie: 'XSRF-TOKEN=foo',
});
const request = new Request('/foo/');
const request = new Request('/foo/', { method: 'POST' });
interceptor(request);
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('foo');
});
it('does not set anything if the cookie is not there', () => {
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', [], {
cookie: 'XXSRF-TOKEN=foo',
});
const request = new Request('/foo/');

View file

@ -16,6 +16,7 @@ export interface AjaxConfig {
xsrfCookieName?: string | null;
xsrfHeaderName?: string | null;
cacheOptions?: CacheOptionsWithIdentifier;
xsrfTrustedOrigins?: string[] | null;
jsonPrefix?: string;
}