From 35a590d0477943e6d069647c6c2c75427ff8ed33 Mon Sep 17 00:00:00 2001
From: Adam Brauer <400763+ambrauer@users.noreply.github.com>
Date: Thu, 19 Oct 2023 14:03:02 -0500
Subject: [PATCH] [Next.js] Deployment Protection Bypass support for Editing
 (#1634)

* Added support for deployment protection bypass query string parameters (or any query string parameters, really) on the editing/render endpoint. Currently, this is setup for Sitecore and Vercel protection bypass query params, but could easily be extended to handle others.

* updated CHANGELOG
---
 CHANGELOG.md                                  |   2 +-
 .../src/editing/constants.ts                  |   3 +
 .../editing/editing-data-middleware.test.ts   |   4 +-
 .../src/editing/editing-data-middleware.ts    |   2 +-
 .../src/editing/editing-data-service.test.ts  |  58 +++++-
 .../src/editing/editing-data-service.ts       |  34 +++-
 .../editing/editing-render-middleware.test.ts | 182 ++++++++++--------
 .../src/editing/editing-render-middleware.ts  |  55 +++++-
 8 files changed, 237 insertions(+), 103 deletions(-)
 create mode 100644 packages/sitecore-jss-nextjs/src/editing/constants.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 425b2b90f6..f901e388db 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,8 +14,8 @@ Our versioning strategy is as follows:
 ### 🎉 New Features & Improvements
 
 * `[templates/nextjs]` Enable client-only BYOC component imports. Client-only components can be imported through src/byoc-imports/index.client.ts. Hybrid (server render with client hydration) components can be imported through src/byoc-imports/index.hybrid.ts. BYOC scaffold logic is also moved from nextjs-sxa addon into base template ([#1628](https://github.com/Sitecore/jss/pull/1628))
-
 * `[templates/nextjs]` Import SitecoreForm component into sample nextjs app ([#1628](https://github.com/Sitecore/jss/pull/1628))
+* `[sitecore-jss-nextjs]` (Vercel/Sitecore) Deployment Protection Bypass support for editing integration. ([#1634](https://github.com/Sitecore/jss/pull/1634))
 
 ### 🐛 Bug Fixes
 
diff --git a/packages/sitecore-jss-nextjs/src/editing/constants.ts b/packages/sitecore-jss-nextjs/src/editing/constants.ts
new file mode 100644
index 0000000000..908e4d1fa8
--- /dev/null
+++ b/packages/sitecore-jss-nextjs/src/editing/constants.ts
@@ -0,0 +1,3 @@
+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';
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 8ff5fdfdd6..bdb7bb2946 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
@@ -2,7 +2,7 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 import { expect, use } from 'chai';
 import { NextApiRequest, NextApiResponse } from 'next';
-import { QUERY_PARAM_EDITING_SECRET } from './editing-data-service';
+import { QUERY_PARAM_EDITING_SECRET } from './constants';
 import { EditingData } from './editing-data';
 import { EditingDataCache } from './editing-data-cache';
 import { EditingDataMiddleware } from './editing-data-middleware';
@@ -42,7 +42,7 @@ const mockCache = (data?: EditingData) => {
   const cache = {} as EditingDataCache;
   cache.set = spy();
   cache.get = spy(() => {
-    return data;
+    return Promise.resolve(data);
   });
   return cache;
 };
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 7f53003880..0af412c141 100644
--- a/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.ts
+++ b/packages/sitecore-jss-nextjs/src/editing/editing-data-middleware.ts
@@ -1,7 +1,7 @@
 import { NextApiRequest, NextApiResponse } from 'next';
 import { EditingDataCache, editingDataDiskCache } from './editing-data-cache';
 import { EditingData, isEditingData } from './editing-data';
-import { QUERY_PARAM_EDITING_SECRET } from './editing-data-service';
+import { QUERY_PARAM_EDITING_SECRET } from './constants';
 import { getJssEditingSecret } from '../utils/utils';
 
 export interface EditingDataMiddlewareConfig {
diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-data-service.test.ts b/packages/sitecore-jss-nextjs/src/editing/editing-data-service.test.ts
index baa6b63892..7342a4a2b6 100644
--- a/packages/sitecore-jss-nextjs/src/editing/editing-data-service.test.ts
+++ b/packages/sitecore-jss-nextjs/src/editing/editing-data-service.test.ts
@@ -8,9 +8,9 @@ import { EditingDataCache } from './editing-data-cache';
 import {
   ServerlessEditingDataService,
   BasicEditingDataService,
-  QUERY_PARAM_EDITING_SECRET,
   generateKey,
 } from './editing-data-service';
+import { QUERY_PARAM_EDITING_SECRET } from './constants';
 import sinonChai from 'sinon-chai';
 import { spy } from 'sinon';
 
@@ -93,6 +93,29 @@ describe('ServerlessEditingDataService', () => {
       });
     });
 
+    it('should invoke PUT request with extra params and return in preview data', async () => {
+      const data = {
+        path: '/styleguide',
+      } as EditingData;
+      const key = '1234key';
+      const serverUrl = 'https://test.com';
+      const paramFoo = 'foo';
+      const paramBar = 'bar';
+      const params = { paramFoo, paramBar };
+      const expectedUrl = `${serverUrl}/api/editing/data/${key}?${QUERY_PARAM_EDITING_SECRET}=${secret}&paramFoo=${paramFoo}&paramBar=${paramBar}`;
+
+      const fetcher = mockFetcher();
+
+      const service = new ServerlessEditingDataService({ dataFetcher: fetcher });
+      service['generateKey'] = () => key;
+
+      return service.setEditingData(data, serverUrl, params).then((previewData) => {
+        expect(previewData.params).to.equal(params);
+        expect(fetcher.put).to.have.been.calledOnce;
+        expect(fetcher.put).to.have.been.calledWithExactly(expectedUrl, data);
+      });
+    });
+
     it('should use custom apiRoute', async () => {
       const data = {
         layoutData: { sitecore: { route: { itemId: 'd6ac9d26-9474-51cf-982d-4f8d44951229' } } },
@@ -115,24 +138,23 @@ describe('ServerlessEditingDataService', () => {
       });
     });
 
-    it('should URI encode secret', async () => {
+    it('should URI encode secret and params', async () => {
       const superSecret = ';,/?:@&=+$';
+      const encodedSecret = encodeURIComponent(superSecret);
       process.env.JSS_EDITING_SECRET = superSecret;
       const data = {
         layoutData: { sitecore: { route: { itemId: 'd6ac9d26-9474-51cf-982d-4f8d44951229' } } },
       } as EditingData;
       const key = '1234key';
       const serverUrl = 'https://test.com';
-      const expectedUrl = `${serverUrl}/api/editing/data/${key}?${QUERY_PARAM_EDITING_SECRET}=${encodeURIComponent(
-        superSecret
-      )}`;
+      const expectedUrl = `${serverUrl}/api/editing/data/${key}?${QUERY_PARAM_EDITING_SECRET}=${encodedSecret}&param=${encodedSecret}`;
 
       const fetcher = mockFetcher();
 
       const service = new ServerlessEditingDataService({ dataFetcher: fetcher });
       service['generateKey'] = () => key;
 
-      return service.setEditingData(data, serverUrl).then(() => {
+      return service.setEditingData(data, serverUrl, { param: superSecret }).then(() => {
         expect(fetcher.put).to.have.been.calledOnce;
         expect(fetcher.put).to.have.been.calledWithExactly(expectedUrl, data);
       });
@@ -159,6 +181,28 @@ describe('ServerlessEditingDataService', () => {
       expect(fetcher.get).to.have.been.calledWith(expectedUrl);
     });
 
+    it('should invoke GET request with extra params', async () => {
+      const data = {
+        path: '/styleguide',
+      } as EditingData;
+      const key = '1234key';
+      const serverUrl = 'https://test.com';
+      const paramFoo = 'foo';
+      const paramBar = 'bar';
+      const params = { paramFoo, paramBar };
+      const expectedUrl = `${serverUrl}/api/editing/data/${key}?${QUERY_PARAM_EDITING_SECRET}=${secret}&paramFoo=${paramFoo}&paramBar=${paramBar}`;
+
+      const fetcher = mockFetcher(data);
+
+      const service = new ServerlessEditingDataService({ dataFetcher: fetcher });
+      service['generateKey'] = () => key;
+
+      const editingData = await service.getEditingData({ key, serverUrl, params });
+      expect(editingData).to.equal(data);
+      expect(fetcher.get).to.have.been.calledOnce;
+      expect(fetcher.get).to.have.been.calledWith(expectedUrl);
+    });
+
     it('should return undefined if serverUrl missing', async () => {
       const data = {
         path: '/styleguide',
@@ -181,7 +225,7 @@ describe('BasicEditingDataService', () => {
     const cache = {} as EditingDataCache;
     cache.set = spy();
     cache.get = spy(() => {
-      return data;
+      return Promise.resolve(data);
     });
     return cache;
   };
diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-data-service.ts b/packages/sitecore-jss-nextjs/src/editing/editing-data-service.ts
index d6a51f9b0e..b03a577342 100644
--- a/packages/sitecore-jss-nextjs/src/editing/editing-data-service.ts
+++ b/packages/sitecore-jss-nextjs/src/editing/editing-data-service.ts
@@ -1,17 +1,17 @@
+import { QUERY_PARAM_EDITING_SECRET } from './constants';
 import { AxiosDataFetcher, debug } from '@sitecore-jss/sitecore-jss';
 import { EditingData } from './editing-data';
 import { EditingDataCache, editingDataDiskCache } from './editing-data-cache';
 import { getJssEditingSecret } from '../utils/utils';
 import { PreviewData } from 'next';
 
-export const QUERY_PARAM_EDITING_SECRET = 'secret';
-
 /**
  * Data for Next.js Preview (Editing) mode
  */
 export interface EditingPreviewData {
   key: string;
   serverUrl?: string;
+  params?: { [key: string]: string };
 }
 
 /**
@@ -24,7 +24,11 @@ export interface EditingDataService {
    * @param {string} serverUrl The server url e.g. which can be used for further API requests
    * @returns The {@link EditingPreviewData} containing the information to use for retrieval
    */
-  setEditingData(data: EditingData, serverUrl: string): Promise<EditingPreviewData>;
+  setEditingData(
+    data: EditingData,
+    serverUrl: string,
+    params?: { [key: string]: string }
+  ): Promise<EditingPreviewData>;
   /**
    * Retrieves Sitecore editor payload data
    * @param {PreviewData} previewData Editing preview data containing the information to use for retrieval
@@ -149,13 +153,18 @@ export class ServerlessEditingDataService implements EditingDataService {
    * @param {string} serverUrl The server url to use for subsequent data API requests
    * @returns {Promise} The {@link EditingPreviewData} containing the generated key and serverUrl to use for retrieval
    */
-  async setEditingData(data: EditingData, serverUrl: string): Promise<EditingPreviewData> {
+  async setEditingData(
+    data: EditingData,
+    serverUrl: string,
+    params?: { [key: string]: string }
+  ): Promise<EditingPreviewData> {
     const key = this.generateKey(data);
-    const url = this.getUrl(serverUrl, key);
+    const url = this.getUrl(serverUrl, key, params);
 
     const previewData = {
       key,
       serverUrl,
+      params,
     } as EditingPreviewData;
 
     debug.editing('storing editing data for %o: %o', previewData, data);
@@ -174,7 +183,11 @@ export class ServerlessEditingDataService implements EditingDataService {
     if (!editingPreviewData?.serverUrl) {
       return undefined;
     }
-    const url = this.getUrl(editingPreviewData.serverUrl, editingPreviewData.key);
+    const url = this.getUrl(
+      editingPreviewData.serverUrl,
+      editingPreviewData.key,
+      editingPreviewData.params
+    );
 
     debug.editing('retrieving editing data for %o', previewData);
     return this.dataFetcher.get<EditingData>(url).then((response: { data: EditingData }) => {
@@ -182,12 +195,19 @@ export class ServerlessEditingDataService implements EditingDataService {
     });
   }
 
-  protected getUrl(serverUrl: string, key: string): string {
+  protected getUrl(serverUrl: string, key: string, params?: { [key: string]: string }): string {
     // Example URL format:
     //  http://localhost:3000/api/editing/data/52961eea-bafd-5287-a532-a72e36bd8a36-qkb4e3fv5x?secret=1234secret
     const apiRoute = this.apiRoute?.replace('[key]', key);
     const url = new URL(apiRoute, serverUrl);
     url.searchParams.append(QUERY_PARAM_EDITING_SECRET, getJssEditingSecret());
+    if (params) {
+      for (const key in params) {
+        if ({}.hasOwnProperty.call(params, key)) {
+          url.searchParams.append(key, params[key]);
+        }
+      }
+    }
     return url.toString();
   }
 }
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 846ff8ef64..7bade6fc70 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
@@ -3,11 +3,12 @@
 import { expect, use } from 'chai';
 import { NextApiRequest, NextApiResponse } from 'next';
 import { AxiosDataFetcher } from '@sitecore-jss/sitecore-jss';
+import { EditingDataService, EditingPreviewData } from './editing-data-service';
 import {
-  EditingDataService,
-  EditingPreviewData,
   QUERY_PARAM_EDITING_SECRET,
-} from './editing-data-service';
+  QUERY_PARAM_PROTECTION_BYPASS_SITECORE,
+  QUERY_PARAM_PROTECTION_BYPASS_VERCEL,
+} from './constants';
 import {
   EE_PATH,
   EE_LANGUAGE,
@@ -63,8 +64,7 @@ const mockResponse = () => {
 
 const mockFetcher = (html?: string) => {
   const fetcher = {} as AxiosDataFetcher;
-  fetcher.get = spy<any>((url, config) => {
-    console.log(url, config);
+  fetcher.get = spy<any>(() => {
     return Promise.resolve({ data: html ?? '' });
   });
   return fetcher;
@@ -204,9 +204,8 @@ describe('EditingRenderMiddleware', () => {
     expect(res.status).to.have.been.calledOnce;
     expect(res.status).to.have.been.calledWith(500);
     expect(res.json).to.have.been.calledOnce;
-    expect(res.json).to.have.been.calledWith({
-      html:
-        '<html><body>Error: Failed to render component for http://localhost:3000/test/path</body></html>',
+    expect(res.json).to.have.been.calledWithMatch({
+      html: '<html><body>Error: Failed to render component for /test/path</body></html>',
     });
   });
 
@@ -415,83 +414,116 @@ describe('EditingRenderMiddleware', () => {
     await handler(req, res);
 
     expect(fetcher.get).to.have.been.calledWithMatch('https://vercel.com');
+  });
 
-    it('should use custom resolveServerUrl', async () => {
-      const html = '<html><body>Something amazing</body></html>';
-      const fetcher = mockFetcher(html);
-      const dataService = mockDataService();
-      const query = {} as Query;
-      query[QUERY_PARAM_EDITING_SECRET] = secret;
-      const req = mockRequest(EE_BODY, query);
-      const res = mockResponse();
-
-      const serverUrl = 'https://test.com';
-
-      const middleware = new EditingRenderMiddleware({
-        dataFetcher: fetcher,
-        editingDataService: dataService,
-        resolveServerUrl: () => {
-          return serverUrl;
-        },
-      });
-      const handler = middleware.getHandler();
+  it('should use custom resolveServerUrl', async () => {
+    const html = '<html><body>Something amazing</body></html>';
+    const fetcher = mockFetcher(html);
+    const dataService = mockDataService();
+    const query = {} as Query;
+    query[QUERY_PARAM_EDITING_SECRET] = secret;
+    const req = mockRequest(EE_BODY, query);
+    const res = mockResponse();
 
-      await handler(req, res);
+    const serverUrl = 'https://test.com';
 
-      expect(fetcher.get).to.have.been.calledWithMatch(serverUrl);
+    const middleware = new EditingRenderMiddleware({
+      dataFetcher: fetcher,
+      editingDataService: dataService,
+      resolveServerUrl: () => {
+        return serverUrl;
+      },
     });
+    const handler = middleware.getHandler();
 
-    it('should use custom resolvePageUrl', async () => {
-      const html = '<html><body>Something amazing</body></html>';
-      const fetcher = mockFetcher(html);
-      const dataService = mockDataService();
-      const query = {} as Query;
-      query[QUERY_PARAM_EDITING_SECRET] = secret;
-      const req = mockRequest(EE_BODY, query);
-      const res = mockResponse();
-
-      const serverUrl = 'https://test.com';
-      const resolvePageUrl = spy((serverUrl: string, itemPath: string) => {
-        return `${serverUrl}/some/path${itemPath}`;
-      });
-
-      const middleware = new EditingRenderMiddleware({
-        dataFetcher: fetcher,
-        editingDataService: dataService,
-        resolvePageUrl: resolvePageUrl,
-        resolveServerUrl: () => {
-          return serverUrl;
-        },
-      });
-      const handler = middleware.getHandler();
+    await handler(req, res);
+
+    expect(fetcher.get).to.have.been.calledWithMatch(serverUrl);
+  });
+
+  it('should use custom resolvePageUrl', async () => {
+    const html = '<html><body>Something amazing</body></html>';
+    const fetcher = mockFetcher(html);
+    const dataService = mockDataService();
+    const query = {} as Query;
+    query[QUERY_PARAM_EDITING_SECRET] = secret;
+    const req = mockRequest(EE_BODY, query);
+    const res = mockResponse();
+
+    const serverUrl = 'https://test.com';
+    const expectedPageUrl = `${serverUrl}/some/path${EE_PATH}`;
+    const resolvePageUrl = spy((serverUrl: string, itemPath: string) => {
+      return `${serverUrl}/some/path${itemPath}`;
+    });
+
+    const middleware = new EditingRenderMiddleware({
+      dataFetcher: fetcher,
+      editingDataService: dataService,
+      resolvePageUrl: resolvePageUrl,
+      resolveServerUrl: () => {
+        return serverUrl;
+      },
+    });
+    const handler = middleware.getHandler();
+
+    await handler(req, res);
+
+    expect(resolvePageUrl).to.have.been.calledOnce;
+    expect(resolvePageUrl).to.have.been.calledWith(serverUrl, EE_PATH);
+    expect(fetcher.get).to.have.been.calledOnce;
+    expect(fetcher.get).to.have.been.calledWithMatch(expectedPageUrl);
+  });
 
-      await handler(req, res);
+  it('should respondWith 500 if rendered html empty', async () => {
+    const fetcher = mockFetcher('');
+    const dataService = mockDataService();
+    const query = {} as Query;
+    query[QUERY_PARAM_EDITING_SECRET] = secret;
+    const req = mockRequest(EE_BODY, query);
+    const res = mockResponse();
 
-      expect(resolvePageUrl).to.have.been.calledOnce;
-      expect(resolvePageUrl).to.have.been.calledWith(EE_PATH);
-      expect(fetcher.get).to.have.been.calledWithMatch(serverUrl);
+    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(500);
+    expect(res.json).to.have.been.calledOnce;
+  });
+
+  it('should pass along protection bypass query parameters', async () => {
+    const html = '<html phkey="test1"><body phkey="test2">Something amazing</body></html>';
+    const query = {} as Query;
+    const bypassTokenSitecore = 'token1234Sitecore';
+    const bypassTokenVercel = 'token1234Vercel';
+    query[QUERY_PARAM_EDITING_SECRET] = secret;
+    query[QUERY_PARAM_PROTECTION_BYPASS_SITECORE] = bypassTokenSitecore;
+    query[QUERY_PARAM_PROTECTION_BYPASS_VERCEL] = bypassTokenVercel;
+    const previewData = { key: 'key1234' } as EditingPreviewData;
+
+    const fetcher = mockFetcher(html);
+    const dataService = mockDataService(previewData);
+    const req = mockRequest(EE_BODY, query);
+    const res = mockResponse();
 
-    it('should respondWith 500 if rendered html empty', async () => {
-      const fetcher = mockFetcher('');
-      const dataService = mockDataService();
-      const query = {} as Query;
-      query[QUERY_PARAM_EDITING_SECRET] = secret;
-      const req = mockRequest(EE_BODY, query);
-      const res = mockResponse();
-
-      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(500);
-      expect(res.json).to.have.been.calledOnce;
+    const middleware = new EditingRenderMiddleware({
+      dataFetcher: fetcher,
+      editingDataService: dataService,
     });
+    const handler = middleware.getHandler();
+
+    await handler(req, res);
+
+    expect(dataService.setEditingData, 'stash editing data').to.have.been.called;
+    expect(res.setPreviewData, 'set preview mode w/ data').to.have.been.calledWith(previewData);
+    expect(fetcher.get).to.have.been.calledOnce;
+    expect(fetcher.get, 'pass along protection bypass query params').to.have.been.calledWithMatch(
+      `http://localhost:3000/test/path?${QUERY_PARAM_PROTECTION_BYPASS_SITECORE}=${bypassTokenSitecore}&${QUERY_PARAM_PROTECTION_BYPASS_VERCEL}=${bypassTokenVercel}&timestamp`
+    );
   });
 
   describe('extractEditingData', () => {
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 e3bc72f213..022b1d0fc3 100644
--- a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts
+++ b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts
@@ -4,11 +4,12 @@ import { AxiosDataFetcher, debug } from '@sitecore-jss/sitecore-jss';
 import { EDITING_COMPONENT_ID, RenderingType } from '@sitecore-jss/sitecore-jss/layout';
 import { parse } from 'node-html-parser';
 import { EditingData } from './editing-data';
+import { EditingDataService, editingDataService } from './editing-data-service';
 import {
-  EditingDataService,
-  editingDataService,
   QUERY_PARAM_EDITING_SECRET,
-} from './editing-data-service';
+  QUERY_PARAM_PROTECTION_BYPASS_SITECORE,
+  QUERY_PARAM_PROTECTION_BYPASS_VERCEL,
+} from './constants';
 import { getJssEditingSecret } from '../utils/utils';
 
 export interface EditingRenderMiddlewareConfig {
@@ -75,6 +76,28 @@ export class EditingRenderMiddleware {
     return this.handler;
   }
 
+  /**
+   * Gets query parameters that should be passed along to subsequent requests
+   * @param query Object of query parameters from incoming URL
+   * @returns Object of approved query parameters
+   */
+  protected getQueryParamsForPropagation = (
+    query: Partial<{ [key: string]: string | string[] }>
+  ): { [key: string]: string } => {
+    const params: { [key: string]: string } = {};
+    if (query[QUERY_PARAM_PROTECTION_BYPASS_SITECORE]) {
+      params[QUERY_PARAM_PROTECTION_BYPASS_SITECORE] = query[
+        QUERY_PARAM_PROTECTION_BYPASS_SITECORE
+      ] as string;
+    }
+    if (query[QUERY_PARAM_PROTECTION_BYPASS_VERCEL]) {
+      params[QUERY_PARAM_PROTECTION_BYPASS_VERCEL] = query[
+        QUERY_PARAM_PROTECTION_BYPASS_VERCEL
+      ] as string;
+    }
+    return params;
+  };
+
   private handler = async (req: NextApiRequest, res: NextApiResponse): Promise<void> => {
     const { method, query, body, headers } = req;
 
@@ -115,10 +138,17 @@ export class EditingRenderMiddleware {
       // Resolve server URL
       const serverUrl = this.resolveServerUrl(req);
 
+      // Get query string parameters to propagate on subsequent requests (e.g. for deployment protection bypass)
+      const params = this.getQueryParamsForPropagation(query);
+
       // Stash for use later on (i.e. within getStatic/ServerSideProps).
       // Note we can't set this directly in setPreviewData since it's stored as a cookie (2KB limit)
       // https://nextjs.org/docs/advanced-features/preview-mode#previewdata-size-limits)
-      const previewData = await this.editingDataService.setEditingData(editingData, serverUrl);
+      const previewData = await this.editingDataService.setEditingData(
+        editingData,
+        serverUrl,
+        params
+      );
 
       // Enable Next.js Preview Mode, passing our preview data (i.e. editingData cache key)
       res.setPreviewData(previewData);
@@ -126,13 +156,18 @@ export class EditingRenderMiddleware {
       // Grab the Next.js preview cookies to send on to the render request
       const cookies = res.getHeader('Set-Cookie') as string[];
 
-      // Make actual render request for page route, passing on preview cookies.
+      // Make actual render request for page route, passing on preview cookies as well as any approved query string parameters.
       // Note timestamp effectively disables caching the request in Axios (no amount of cache headers seemed to do it)
-      const requestUrl = this.resolvePageUrl(serverUrl, editingData.path);
       debug.editing('fetching page route for %s', editingData.path);
-      const queryStringCharacter = requestUrl.indexOf('?') === -1 ? '?' : '&';
+      const requestUrl = new URL(this.resolvePageUrl(serverUrl, editingData.path));
+      for (const key in params) {
+        if ({}.hasOwnProperty.call(params, key)) {
+          requestUrl.searchParams.append(key, params[key]);
+        }
+      }
+      requestUrl.searchParams.append('timestamp', Date.now().toString());
       const pageRes = await this.dataFetcher
-        .get<string>(`${requestUrl}${queryStringCharacter}timestamp=${Date.now()}`, {
+        .get<string>(requestUrl.toString(), {
           headers: {
             Cookie: cookies.join(';'),
           },
@@ -149,7 +184,7 @@ export class EditingRenderMiddleware {
 
       let html = pageRes.data;
       if (!html || html.length === 0) {
-        throw new Error(`Failed to render html for ${requestUrl}`);
+        throw new Error(`Failed to render html for ${editingData.path}`);
       }
 
       // replace phkey attribute with key attribute so that newly added renderings
@@ -168,7 +203,7 @@ export class EditingRenderMiddleware {
         // Handle component rendering. Extract component markup only
         html = parse(html).getElementById(EDITING_COMPONENT_ID)?.innerHTML;
 
-        if (!html) throw new Error(`Failed to render component for ${requestUrl}`);
+        if (!html) throw new Error(`Failed to render component for ${editingData.path}`);
       }
 
       const body = { html };