@lion/ajax: only add XSRF token on mutable requests and on same origin or whitelisted origins
This commit is contained in:
parent
eb46728826
commit
04d08683f7
6 changed files with 122 additions and 11 deletions
12
.changeset/two-plums-run.md
Normal file
12
.changeset/two-plums-run.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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/');
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export interface AjaxConfig {
|
|||
xsrfCookieName?: string | null;
|
||||
xsrfHeaderName?: string | null;
|
||||
cacheOptions?: CacheOptionsWithIdentifier;
|
||||
xsrfTrustedOrigins?: string[] | null;
|
||||
jsonPrefix?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue