diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cea1443ea..20f811a3b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ Our versioning strategy is as follows: * `[sitecore-jss-react]`Introduce ErrorBoundary component. All rendered components are wrapped with it and it will catch client or server side errors from any of its children, display appropriate message and prevent the rest of the application from failing. It accepts and can display custom error component and loading message if it is passed as a prop to parent Placeholder. ([#1786](https://github.com/Sitecore/jss/pull/1786) [#1790](https://github.com/Sitecore/jss/pull/1790) [#1793](https://github.com/Sitecore/jss/pull/1793) [#1794](https://github.com/Sitecore/jss/pull/1794)) +* `[sitecore-jss-nextjs]` Enforce CORS policy that matches Sitecore Pages domains for editing middleware API endpoints ([#1798](https://github.com/Sitecore/jss/pull/1798)) + ## 22.0.0 ### 🛠 Breaking Changes diff --git a/packages/sitecore-jss-nextjs/src/editing/constants.ts b/packages/sitecore-jss-nextjs/src/editing/constants.ts index 908e4d1fa8..6bde41577f 100644 --- a/packages/sitecore-jss-nextjs/src/editing/constants.ts +++ b/packages/sitecore-jss-nextjs/src/editing/constants.ts @@ -1,3 +1,4 @@ export const QUERY_PARAM_EDITING_SECRET = 'secret'; export const QUERY_PARAM_PROTECTION_BYPASS_SITECORE = 'x-sitecore-protection-bypass'; export const QUERY_PARAM_PROTECTION_BYPASS_VERCEL = 'x-vercel-protection-bypass'; +export const EDITING_ALLOWED_ORIGINS = ['https://pages*.cloud/', 'https://pages.sitecorecloud.io/']; diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-config-middleware.test.ts b/packages/sitecore-jss-nextjs/src/editing/editing-config-middleware.test.ts index 7f41514412..c0576fa00b 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-config-middleware.test.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-config-middleware.test.ts @@ -9,10 +9,16 @@ type Query = { [key: string]: string; }; -const mockRequest = (method: string, query?: Query) => { +const allowedOrigin = 'https://allowed.com'; + +const mockRequest = (method: string, query?: Query, headers?: { [key: string]: string }) => { return { method, query: query ?? {}, + headers: { + origin: allowedOrigin, + ...headers, + }, } as NextApiRequest; }; @@ -24,6 +30,9 @@ const mockResponse = () => { res.json = spy(() => { return res; }); + res.setHeader = spy(() => { + return res; + }); return res; }; @@ -44,10 +53,12 @@ describe('EditingConfigMiddleware', () => { beforeEach(() => { process.env.JSS_EDITING_SECRET = secret; + process.env.JSS_ALLOWED_ORIGINS = allowedOrigin; }); after(() => { delete process.env.JSS_EDITING_SECRET; + delete process.env.JSS_ALLOWED_ORIGINS; }); it('should respond with 401 for missing secret', async () => { @@ -66,6 +77,20 @@ describe('EditingConfigMiddleware', () => { expect(res.json).to.have.been.calledWith(expectedResultForbidden); }); + it('should stop request and return 401 when CORS match is not met', async () => { + const req = mockRequest('GET', {}, { origin: 'https://notallowed.com' }); + const res = mockResponse(); + const middleware = new EditingConfigMiddleware({ components: componentsArray, metadata }); + const handler = middleware.getHandler(); + + await handler(req, res); + + expect(res.status).to.have.been.calledOnce; + expect(res.status).to.have.been.calledWith(401); + expect(res.json).to.have.been.calledOnce; + expect(res.json).to.have.been.calledWith({ message: 'Invalid origin' }); + }); + it('should respond with 401 for invalid secret', async () => { const key = 'wrongkey'; const query = { key } as Query; diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-config-middleware.ts b/packages/sitecore-jss-nextjs/src/editing/editing-config-middleware.ts index 0de40da321..2cefa06506 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-config-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-config-middleware.ts @@ -1,8 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { QUERY_PARAM_EDITING_SECRET } from './constants'; +import { EDITING_ALLOWED_ORIGINS, QUERY_PARAM_EDITING_SECRET } from './constants'; import { getJssEditingSecret } from '../utils/utils'; import { debug } from '@sitecore-jss/sitecore-jss'; import { Metadata } from '@sitecore-jss/sitecore-jss-dev-tools'; +import { enforceCors } from '@sitecore-jss/sitecore-jss/utils'; export type EditingConfigMiddlewareConfig = { /** @@ -35,6 +36,12 @@ export class EditingConfigMiddleware { private handler = async (_req: NextApiRequest, res: NextApiResponse): Promise => { const secret = _req.query[QUERY_PARAM_EDITING_SECRET]; + if (!enforceCors(_req, res, EDITING_ALLOWED_ORIGINS)) { + debug.editing( + 'invalid origin host - set allowed origins in JSS_ALLOWED_ORIGINS environment variable' + ); + return res.status(401).json({ message: 'Invalid origin' }); + } if (secret !== getJssEditingSecret()) { debug.editing( 'invalid editing secret - sent "%s" expected "%s"', diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.test.ts b/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.test.ts index bdb7bb2946..b6a7393d4a 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.test.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.test.ts @@ -15,11 +15,22 @@ type Query = { [key: string]: string; }; -const mockRequest = (method: string, query?: Query, body?: unknown) => { +const allowedOrigin = 'https://allowed.com'; + +const mockRequest = ( + method: string, + query?: Query, + body?: unknown, + headers?: { [key: string]: string } +) => { return { method, query: query ?? {}, body: body ?? {}, + headers: { + origin: allowedOrigin, + ...headers, + }, } as NextApiRequest; }; @@ -34,7 +45,9 @@ const mockResponse = () => { res.end = spy(() => { return res; }); - res.setHeader = spy(); + res.setHeader = spy(() => { + return res; + }); return res; }; @@ -59,10 +72,12 @@ describe('EditingDataMiddleware', () => { beforeEach(() => { process.env.JSS_EDITING_SECRET = secret; + process.env.JSS_ALLOWED_ORIGINS = allowedOrigin; }); after(() => { delete process.env.JSS_EDITING_SECRET; + delete process.env.JSS_ALLOWED_ORIGINS; }); it('should handle PUT request', async () => { @@ -132,6 +147,21 @@ describe('EditingDataMiddleware', () => { expect(res.json).to.have.been.calledWith(mockEditingData); }); + it('should stop request and return 401 when CORS match is not met', async () => { + const req = mockRequest('GET', {}, {}, { origin: 'https://notallowed.com' }); + const res = mockResponse(); + const cache = mockCache(); + const middleware = new EditingDataMiddleware({ editingDataCache: cache }); + const handler = middleware.getHandler(); + + await handler(req, res); + + expect(res.status).to.have.been.calledOnce; + expect(res.status).to.have.been.calledWith(401); + expect(res.json).to.have.been.calledOnce; + expect(res.json).to.have.been.calledWith({ message: 'Invalid origin' }); + }); + it('should respond with 400 for invalid editing data', async () => { const key = 'key1234'; const query = { key } as Query; diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.ts b/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.ts index 0af412c141..9cce3a2fd4 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.ts @@ -1,8 +1,10 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { EditingDataCache, editingDataDiskCache } from './editing-data-cache'; import { EditingData, isEditingData } from './editing-data'; -import { QUERY_PARAM_EDITING_SECRET } from './constants'; +import { EDITING_ALLOWED_ORIGINS, QUERY_PARAM_EDITING_SECRET } from './constants'; import { getJssEditingSecret } from '../utils/utils'; +import { enforceCors } from '@sitecore-jss/sitecore-jss/utils'; +import { debug } from '@sitecore-jss/sitecore-jss'; export interface EditingDataMiddlewareConfig { /** @@ -51,6 +53,12 @@ export class EditingDataMiddleware { const secret = query[QUERY_PARAM_EDITING_SECRET]; const key = query[this.queryParamKey]; + if (!enforceCors(req, res, EDITING_ALLOWED_ORIGINS)) { + debug.editing( + 'invalid origin host - set allowed origins in JSS_ALLOWED_ORIGINS environment variable' + ); + return res.status(401).json({ message: 'Invalid origin' }); + } // Validate secret if (secret !== getJssEditingSecret()) { res.status(401).end('Missing or invalid secret'); diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts index 7bade6fc70..a6db9fb907 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts @@ -32,12 +32,23 @@ type Query = { [key: string]: string; }; -const mockRequest = (body?: any, query?: Query, method?: string, host?: string) => { +const allowedOrigin = 'https://allowed.com'; + +const mockRequest = ( + body?: any, + query?: Query, + method?: string, + headers?: { [key: string]: string } +) => { return { body: body ?? {}, method: method ?? 'POST', query: query ?? {}, - headers: { host: host ?? 'localhost:3000' }, + headers: { + host: 'localhost:3000', + origin: allowedOrigin, + ...headers, + }, } as NextApiRequest; }; @@ -83,12 +94,14 @@ describe('EditingRenderMiddleware', () => { beforeEach(() => { process.env.JSS_EDITING_SECRET = secret; + process.env.JSS_ALLOWED_ORIGINS = allowedOrigin; delete process.env.VERCEL; }); after(() => { delete process.env.JSS_EDITING_SECRET; delete process.env.VERCEL; + delete process.env.JSS_ALLOWED_ORIGINS; }); it('should handle request', async () => { @@ -312,6 +325,27 @@ describe('EditingRenderMiddleware', () => { expect(res.json).to.have.been.calledOnce; }); + it('should stop request and return 401 when CORS match is not met', async () => { + const req = mockRequest({}, {}, 'POST', { origin: 'https://notallowed.com' }); + const res = mockResponse(); + const fetcher = mockFetcher(); + const dataService = mockDataService(); + const middleware = new EditingRenderMiddleware({ + dataFetcher: fetcher, + editingDataService: dataService, + }); + const handler = middleware.getHandler(); + + await handler(req, res); + + expect(res.status).to.have.been.calledOnce; + expect(res.status).to.have.been.calledWith(401); + expect(res.json).to.have.been.calledOnce; + expect(res.json).to.have.been.calledWith({ + html: 'Requests from origin https://notallowed.com not allowed', + }); + }); + it('should respond with 401 for missing secret', async () => { const fetcher = mockFetcher(); const dataService = mockDataService(); @@ -381,7 +415,7 @@ describe('EditingRenderMiddleware', () => { const dataService = mockDataService(); const query = {} as Query; query[QUERY_PARAM_EDITING_SECRET] = secret; - const req = mockRequest(EE_BODY, query, undefined, 'testhostheader.com'); + const req = mockRequest(EE_BODY, query, undefined, { host: 'testhostheader.com' }); const res = mockResponse(); const middleware = new EditingRenderMiddleware({ @@ -401,7 +435,7 @@ describe('EditingRenderMiddleware', () => { const dataService = mockDataService(); const query = {} as Query; query[QUERY_PARAM_EDITING_SECRET] = secret; - const req = mockRequest(EE_BODY, query, undefined, 'vercel.com'); + const req = mockRequest(EE_BODY, query, undefined, { host: 'vercel.com' }); const res = mockResponse(); process.env.VERCEL = '1'; diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts index 789121278f..634bd14f16 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts @@ -5,9 +5,10 @@ import { EDITING_COMPONENT_ID, RenderingType } from '@sitecore-jss/sitecore-jss/ import { parse } from 'node-html-parser'; import { EditingData } from './editing-data'; import { EditingDataService, editingDataService } from './editing-data-service'; -import { QUERY_PARAM_EDITING_SECRET } from './constants'; +import { EDITING_ALLOWED_ORIGINS, QUERY_PARAM_EDITING_SECRET } from './constants'; import { getJssEditingSecret } from '../utils/utils'; import { RenderMiddlewareBase } from './render-middleware'; +import { enforceCors } from '@sitecore-jss/sitecore-jss/utils'; export interface EditingRenderMiddlewareConfig { /** @@ -87,6 +88,15 @@ export class EditingRenderMiddleware extends RenderMiddlewareBase { body, }); + if (!enforceCors(req, res, EDITING_ALLOWED_ORIGINS)) { + debug.editing( + 'invalid origin host - set allowed origins in JSS_ALLOWED_ORIGINS environment variable' + ); + return res.status(401).json({ + html: `Requests from origin ${req.headers?.origin} not allowed`, + }); + } + if (method !== 'POST') { debug.editing('invalid method - sent %s expected POST', method); res.setHeader('Allow', 'POST'); diff --git a/packages/sitecore-jss-nextjs/src/editing/feaas-render-middleware.test.ts b/packages/sitecore-jss-nextjs/src/editing/feaas-render-middleware.test.ts index 3b98b23ab3..0140b956e1 100644 --- a/packages/sitecore-jss-nextjs/src/editing/feaas-render-middleware.test.ts +++ b/packages/sitecore-jss-nextjs/src/editing/feaas-render-middleware.test.ts @@ -18,12 +18,18 @@ type Query = { [key: string]: string; }; -const mockRequest = (query?: Query, method?: string, host?: string) => { +const allowedOrigin = 'https://allowed.com'; + +const mockRequest = (query?: Query, method?: string, headers?: { [key: string]: string }) => { return { body: {}, method: method ?? 'GET', query: query ?? {}, - headers: { host: host ?? 'localhost:3000' }, + headers: { + host: 'localhost:3000', + origin: allowedOrigin, + ...headers, + }, } as NextApiRequest; }; @@ -53,10 +59,12 @@ describe('FEAASRenderMiddleware', () => { beforeEach(() => { process.env.JSS_EDITING_SECRET = secret; + process.env.JSS_ALLOWED_ORIGINS = allowedOrigin; }); after(() => { delete process.env.JSS_EDITING_SECRET; + delete process.env.JSS_ALLOWED_ORIGINS; }); it('should handle request', async () => { @@ -138,6 +146,22 @@ describe('FEAASRenderMiddleware', () => { ); }); + it('should stop request and return 401 when CORS match is not met', async () => { + const req = mockRequest({}, 'POST', { origin: 'https://notallowed.com' }); + const res = mockResponse(); + const middleware = new FEAASRenderMiddleware(); + const handler = middleware.getHandler(); + + await handler(req, res); + + expect(res.status).to.have.been.calledOnce; + expect(res.status).to.have.been.calledWith(401); + expect(res.send).to.have.been.calledOnce; + expect(res.send).to.have.been.calledWith( + 'Requests from origin https://notallowed.com are not allowed' + ); + }); + it('should respond with 401 for missing secret', async () => { const query = {} as Query; const req = mockRequest(query); diff --git a/packages/sitecore-jss-nextjs/src/editing/feaas-render-middleware.ts b/packages/sitecore-jss-nextjs/src/editing/feaas-render-middleware.ts index 98c2e53a28..3e7772ba50 100644 --- a/packages/sitecore-jss-nextjs/src/editing/feaas-render-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/editing/feaas-render-middleware.ts @@ -1,8 +1,9 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { debug } from '@sitecore-jss/sitecore-jss'; -import { QUERY_PARAM_EDITING_SECRET } from './constants'; +import { EDITING_ALLOWED_ORIGINS, QUERY_PARAM_EDITING_SECRET } from './constants'; import { getJssEditingSecret } from '../utils/utils'; import { RenderMiddlewareBase } from './render-middleware'; +import { enforceCors } from '@sitecore-jss/sitecore-jss/utils'; /** * Configuration for `FEAASRenderMiddleware`. @@ -52,6 +53,17 @@ export class FEAASRenderMiddleware extends RenderMiddlewareBase { headers, }); + if (!enforceCors(req, res, EDITING_ALLOWED_ORIGINS)) { + debug.editing( + 'invalid origin host - set allowed origins in JSS_ALLOWED_ORIGINS environment variable' + ); + return res + .status(401) + .send( + `Requests from origin ${req.headers?.origin} are not allowed` + ); + } + if (method !== 'GET') { debug.editing('invalid method - sent %s expected GET', method); res.setHeader('Allow', 'GET'); diff --git a/packages/sitecore-jss/src/utils/index.ts b/packages/sitecore-jss/src/utils/index.ts index cde295e130..1706233ed1 100644 --- a/packages/sitecore-jss/src/utils/index.ts +++ b/packages/sitecore-jss/src/utils/index.ts @@ -1,5 +1,5 @@ export { default as isServer } from './is-server'; -export { resolveUrl, isAbsoluteUrl, isTimeoutError } from './utils'; +export { resolveUrl, isAbsoluteUrl, isTimeoutError, enforceCors } from './utils'; export { tryParseEnvValue } from './env'; export { ExperienceEditor, diff --git a/packages/sitecore-jss/src/utils/utils.test.ts b/packages/sitecore-jss/src/utils/utils.test.ts index c6a5baed1e..00669e380a 100644 --- a/packages/sitecore-jss/src/utils/utils.test.ts +++ b/packages/sitecore-jss/src/utils/utils.test.ts @@ -2,7 +2,8 @@ import { expect, spy } from 'chai'; import { isEditorActive, resetEditorChromes, isServer, resolveUrl } from '.'; import { ChromeRediscoveryGlobalFunctionName } from './editing'; -import { isAbsoluteUrl, isTimeoutError } from './utils'; +import { enforceCors, isAbsoluteUrl, isTimeoutError } from './utils'; +import { IncomingMessage, OutgoingMessage } from 'http'; // must make TypeScript happy with `global` variable modification interface CustomWindow { @@ -181,4 +182,65 @@ describe('utils', () => { expect(isTimeoutError({ name: 'AbortError' })).to.be.true; }); }); + + describe('enforceCors', () => { + const mockOrigin = 'https://maybeallowed.com'; + const mockRequest = (origin?: string) => { + return { + headers: { + origin: origin || mockOrigin, + }, + } as IncomingMessage; + }; + + const mockResponse = () => { + const res = {} as OutgoingMessage; + res.setHeader = spy(() => { + return res; + }); + + return res; + }; + + it('should return true if origin is found in allowedOrigins from JSS_ALLOWED_ORIGINS env variable', () => { + const req = mockRequest(); + const res = mockResponse(); + process.env.JSS_ALLOWED_ORIGINS = mockOrigin; + expect(enforceCors(req, res)).to.be.equal(true); + delete process.env.JSS_ALLOWED_ORIGINS; + }); + + it('should return true if origin is found in allowedOrigins passed as argument', () => { + const req = mockRequest('http://allowed.com'); + const res = mockResponse(); + + expect(enforceCors(req, res, ['http://allowed.com'])).to.be.equal(true); + }); + + it('should return false if origin matches neither allowedOrigins from JSS_ALLOWED_ORIGINS env variable nor argument', () => { + const req = mockRequest('https://notallowed.com'); + const res = mockResponse(); + process.env.JSS_ALLOWED_ORIGINS = 'https://strictallowed.com, https://alsoallowed.com'; + expect(enforceCors(req, res, ['https://paramallowed.com'])).to.be.equal(false); + delete process.env.JSS_ALLOWED_ORIGINS; + }); + + it('should return true when origin matches a wildcard value from allowedOrigins', () => { + const req = mockRequest('https://allowed.dev.com'); + const res = mockResponse(); + expect(enforceCors(req, res, ['https://allowed.*.com'])).to.be.equal(true); + }); + + it('should set Access-Control-Allow-Origin and Access-Control-Allow-Methods headers for matching origin', () => { + const req = mockRequest(); + const res = mockResponse(); + const allowedMethods = 'GET, POST, OPTIONS, DELETE, PUT, PATCH'; + enforceCors(req, res, [mockOrigin]); + expect(res.setHeader).to.have.been.called.with('Access-Control-Allow-Origin', mockOrigin); + expect(res.setHeader).to.have.been.called.with( + 'Access-Control-Allow-Methods', + allowedMethods + ); + }); + }); }); diff --git a/packages/sitecore-jss/src/utils/utils.ts b/packages/sitecore-jss/src/utils/utils.ts index 137a0718de..e82989f2c8 100644 --- a/packages/sitecore-jss/src/utils/utils.ts +++ b/packages/sitecore-jss/src/utils/utils.ts @@ -2,6 +2,7 @@ import isServer from './is-server'; import { ParsedUrlQueryInput } from 'querystring'; import { AxiosError } from 'axios'; import { ResponseError } from '../data-fetcher'; +import { IncomingMessage, OutgoingMessage } from 'http'; /** * note: encodeURIComponent is available via browser (window) or natively in node.js @@ -75,3 +76,52 @@ export const isTimeoutError = (error: unknown) => { (error as Error).name === 'AbortError' ); }; + +/** + * Converts a string value in a regex pattern allowing wildcard matching + * @param {string} pattern input with wildcards i.e. site.*.com + * @returns {string} modified string that can be used as regexp input + */ +const convertToWildcardRegex = (pattern: string) => { + return ( + '^' + + pattern + .replace(/\//g, '\\/') + .replace(/\./g, '\\.') + .replace(/\*/g, '.*') + + '$' + ); +}; + +/** + * Tests origin from incoming request against allowed origins list that can be + * set in JSS's JSS_ALLOWED_ORIGINS env variable and/or passed via allowedOrigins param. + * Applies Access-Control-Allow-Origin and Access-Control-Allow-Methods on match + * @param {IncomingMessage} req incoming request + * @param {OutgoingMessage} res response to set CORS headers for + * @param {string[]} [allowedOrigins] additional list of origins to test against + * @returns true if incoming origin matches the allowed lists, false when it does not + */ +export const enforceCors = ( + req: IncomingMessage, + res: OutgoingMessage, + allowedOrigins?: string[] +): boolean => { + const defaultAllowedOrigins = process.env.JSS_ALLOWED_ORIGINS + ? process.env.JSS_ALLOWED_ORIGINS.replace(' ', '').split(',') + : []; + allowedOrigins = defaultAllowedOrigins.concat(allowedOrigins || []); + const origin = req.headers.origin; + if ( + origin && + allowedOrigins.some( + (allowedOrigin) => + origin === allowedOrigin || new RegExp(convertToWildcardRegex(allowedOrigin)).test(origin) + ) + ) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE, PUT, PATCH'); + return true; + } + return false; +};