diff --git a/.changeset/two-plums-run.md b/.changeset/two-plums-run.md new file mode 100644 index 000000000..527544759 --- /dev/null +++ b/.changeset/two-plums-run.md @@ -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. diff --git a/packages/ajax/src/Ajax.js b/packages/ajax/src/Ajax.js index 5831524b6..f79da2516 100644 --- a/packages/ajax/src/Ajax.js +++ b/packages/ajax/src/Ajax.js @@ -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 diff --git a/packages/ajax/src/interceptors/xsrfHeader.js b/packages/ajax/src/interceptors/xsrfHeader.js index 19115d567..cc76c4825 100644 --- a/packages/ajax/src/interceptors/xsrfHeader.js +++ b/packages/ajax/src/interceptors/xsrfHeader.js @@ -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; diff --git a/packages/ajax/test/Ajax.test.js b/packages/ajax/test/Ajax.test.js index 82ea9cdfa..4e0d82a81 100644 --- a/packages/ajax/test/Ajax.test.js +++ b/packages/ajax/test/Ajax.test.js @@ -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', () => { diff --git a/packages/ajax/test/interceptors/xsrfHeader.test.js b/packages/ajax/test/interceptors/xsrfHeader.test.js index 938d26082..95d3cf440 100644 --- a/packages/ajax/test/interceptors/xsrfHeader.test.js +++ b/packages/ajax/test/interceptors/xsrfHeader.test.js @@ -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/'); diff --git a/packages/ajax/types/types.ts b/packages/ajax/types/types.ts index 9a71351ac..d4bc53152 100644 --- a/packages/ajax/types/types.ts +++ b/packages/ajax/types/types.ts @@ -16,6 +16,7 @@ export interface AjaxConfig { xsrfCookieName?: string | null; xsrfHeaderName?: string | null; cacheOptions?: CacheOptionsWithIdentifier; + xsrfTrustedOrigins?: string[] | null; jsonPrefix?: string; }