From ca0419b1b8420cef3975c9ecd0a54f4285723340 Mon Sep 17 00:00:00 2001 From: Parth Shah Date: Wed, 7 Oct 2026 16:05:53 -0500 Subject: [PATCH] TRAC-1688: feat - Require x-stencil-preview-secret on tunnel requests --- lib/stencil-start.js | 8 ++-- lib/stencil-start.spec.js | 8 +++- server/index.js | 8 +++- server/index.spec.js | 24 ++++++++++ .../preview-auth/preview-auth.module.js | 38 +++++++++++++++ .../preview-auth/preview-auth.module.spec.js | 46 +++++++++++++++++++ 6 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 server/index.spec.js create mode 100644 server/plugins/preview-auth/preview-auth.module.js create mode 100644 server/plugins/preview-auth/preview-auth.module.spec.js diff --git a/lib/stencil-start.js b/lib/stencil-start.js index 69e66268..61a8234c 100755 --- a/lib/stencil-start.js +++ b/lib/stencil-start.js @@ -91,16 +91,15 @@ class StencilStart { 'Makeswift preview requires an access token. Add accessToken to your .stencil file.'.red, ); } - const previewSecret = makeswiftPreview ? this._previewSessionClient.createSecret() : null; - await this.startLocalServer(cliOptions, updatedStencilConfig); if (makeswiftPreview) { await this.startMakeswiftPreview({ rendererPort: this.getRendererPort(browserSyncPort), storeUrl: previewStoreUrl, accessToken: updatedStencilConfig.accessToken, - secret: previewSecret, + secret: this._previewSessionClient.createSecret(), }); } + await this.startLocalServer(cliOptions, updatedStencilConfig, this._previewSession?.secret); this._logger.log(this.getStartUpInfo(updatedStencilConfig)); await this.startBrowserSync(cliOptions, browserSyncPort); } @@ -275,8 +274,9 @@ class StencilStart { * @param {Object} stencilConfig * @return {Promise} */ - async startLocalServer(cliOptions, stencilConfig) { + async startLocalServer(cliOptions, stencilConfig, previewSecret) { return Server.create({ + previewSecret, dotStencilFile: stencilConfig, variationIndex: this._themeConfigManager.variationIndex || 0, useCache: cliOptions.cache, diff --git a/lib/stencil-start.spec.js b/lib/stencil-start.spec.js index 52a6bd34..e34f8a6a 100644 --- a/lib/stencil-start.spec.js +++ b/lib/stencil-start.spec.js @@ -279,7 +279,7 @@ describe('StencilStart unit tests', () => { expect(browserSync.init).toHaveBeenCalled(); }); - it('should register the session on the store host and keep it in memory', async () => { + it('should register the session and require its secret on the renderer', async () => { const { previewSessionClient, instance } = await runStart(); expect(previewSessionClient.register).toHaveBeenCalledWith({ storeUrl: 'https://www.example.com', @@ -293,6 +293,11 @@ describe('StencilStart unit tests', () => { secret: previewSecret, ...registeredSession, }); + expect(instance.startLocalServer).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + previewSecret, + ); }); it('should stop the tunnel and continue without preview when registration fails', async () => { @@ -306,6 +311,7 @@ describe('StencilStart unit tests', () => { const { stop } = await tunnelManager.start.mock.results[0].value; expect(stop).toHaveBeenCalled(); expect(instance.previewSession).toBeNull(); + expect(instance.startLocalServer.mock.calls[0][2]).toBeUndefined(); expect(output).toContain('Makeswift preview is not available. Could not register'); expect(browserSync.init).toHaveBeenCalled(); }); diff --git a/server/index.js b/server/index.js index 52979d4f..50bb15c8 100644 --- a/server/index.js +++ b/server/index.js @@ -8,7 +8,7 @@ import 'colors'; const getDirname = dirname(fileURLToPath(import.meta.url)); -function buildManifest(srcManifest, options) { +export function buildManifest(srcManifest, options) { const resManifest = _.cloneDeep(srcManifest); const pluginsByName = resManifest.register.plugins; const parsedSecureUrl = new URL(options.dotStencilFile.storeUrl); // The url to a secure page (prompted as login page) @@ -42,6 +42,12 @@ function buildManifest(srcManifest, options) { (pluginsArr, opts, plugin) => [...pluginsArr, { plugin, options: opts }], [], ); + if (options.previewSecret) { + resManifest.register.plugins.unshift({ + plugin: './plugins/preview-auth/preview-auth.module.js', + options: { secret: options.previewSecret }, + }); + } return resManifest; } async function create(options) { diff --git a/server/index.spec.js b/server/index.spec.js new file mode 100644 index 00000000..44f271a6 --- /dev/null +++ b/server/index.spec.js @@ -0,0 +1,24 @@ +import { buildManifest } from './index.js'; +import * as manifest from './manifest.js'; + +describe('buildManifest', () => { + const dotStencilFile = { + storeUrl: 'https://store-abc123.mybigcommerce.com', + normalStoreUrl: 'https://www.example.com', + port: 3000, + }; + const PREVIEW_AUTH = './plugins/preview-auth/preview-auth.module.js'; + const plugins = (options) => + buildManifest(manifest.get('/'), { dotStencilFile, ...options }).register.plugins; + + it('should register PreviewAuth first, with the preview secret', () => { + expect(plugins({ previewSecret: 'secret' })[0]).toEqual({ + plugin: PREVIEW_AUTH, + options: { secret: 'secret' }, + }); + }); + + it('should not load PreviewAuth when there is no preview session', () => { + expect(plugins({}).map(({ plugin }) => plugin)).not.toContain(PREVIEW_AUTH); + }); +}); diff --git a/server/plugins/preview-auth/preview-auth.module.js b/server/plugins/preview-auth/preview-auth.module.js new file mode 100644 index 00000000..07cc376e --- /dev/null +++ b/server/plugins/preview-auth/preview-auth.module.js @@ -0,0 +1,38 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import Boom from '@hapi/boom'; + +const PREVIEW_SECRET_HEADER = 'x-stencil-preview-secret'; + +// Hash both values so the comparison takes the same time for any input length. +function secretsMatch(expected, supplied) { + const digest = (value) => createHash('sha256').update(value).digest(); + return timingSafeEqual(digest(expected), digest(supplied)); +} + +/** + * Loaded only in Makeswift preview mode. + */ +function register(server, { secret }) { + if (typeof secret !== 'string' || !secret) { + throw new Error('PreviewAuth requires a non-empty secret'); + } + server.ext('onRequest', (request, h) => { + const supplied = request.headers[PREVIEW_SECRET_HEADER]; + if (typeof supplied !== 'string') { + return Boom.unauthorized(); + } + if (!secretsMatch(secret, supplied)) { + return Boom.forbidden(); + } + delete request.headers[PREVIEW_SECRET_HEADER]; + return h.continue; + }); +} +export const name = 'PreviewAuth'; +export const version = '0.0.1'; +export { register }; +export default { + register, + name, + version, +}; diff --git a/server/plugins/preview-auth/preview-auth.module.spec.js b/server/plugins/preview-auth/preview-auth.module.spec.js new file mode 100644 index 00000000..d52085dd --- /dev/null +++ b/server/plugins/preview-auth/preview-auth.module.spec.js @@ -0,0 +1,46 @@ +import { jest } from '@jest/globals'; +import * as Hapi from '@hapi/hapi'; +import previewAuth from './preview-auth.module.js'; + +describe('PreviewAuth', () => { + const secret = 'a'.repeat(64); + const server = new Hapi.Server(); + const handler = jest.fn(() => 'OK'); + + beforeAll(async () => { + await server.register({ plugin: previewAuth, options: { secret } }); + server.route({ method: '*', path: '/{path*}', handler }); + }); + afterEach(() => handler.mockClear()); + + it('should return 401 and not run the route when the secret is missing', async () => { + const response = await server.inject({ url: '/internalapi/carts' }); + expect(response.statusCode).toEqual(401); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should return 403 and not run the route when the secret is wrong', async () => { + const response = await server.inject({ + url: '/test', + headers: { 'x-stencil-preview-secret': 'wrong' }, + }); + expect(response.statusCode).toEqual(403); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should run the route without the header when the secret is correct', async () => { + const response = await server.inject({ + url: '/test', + headers: { 'x-stencil-preview-secret': secret }, + }); + expect(response.statusCode).toEqual(200); + const [request] = handler.mock.calls[0]; + expect(request.headers).not.toHaveProperty('x-stencil-preview-secret'); + }); + + it('should fail to register without a secret', async () => { + await expect( + new Hapi.Server().register({ plugin: previewAuth, options: { secret: '' } }), + ).rejects.toThrow('PreviewAuth requires a non-empty secret'); + }); +});