diff --git a/lib/preview-proxy.js b/lib/preview-proxy.js new file mode 100644 index 00000000..5d1b83bf --- /dev/null +++ b/lib/preview-proxy.js @@ -0,0 +1,224 @@ +import crypto from 'crypto'; +import httpProxy from 'http-proxy'; + +const PREVIEW_HEADER_PREFIX = 'x-stencil-preview-'; +const SESSION_HEADER = 'x-stencil-preview-session'; +const TIMESTAMP_HEADER = 'x-stencil-preview-timestamp'; +const SIGNATURE_HEADER = 'x-stencil-preview-signature'; +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** + * Normalizes the request target. + * + * @param {string} rawUrl - request target from the incoming request + * @param {string} origin - storefront origin + * @returns {string | null} null when the target is not origin-form + */ +function getSignedPath(rawUrl, origin) { + if (typeof rawUrl !== 'string' || !rawUrl.startsWith('/')) { + return null; + } + try { + const url = new URL(`${origin}${rawUrl}`); + return url.origin === origin ? `${url.pathname}${url.search}` : null; + } catch { + return null; + } +} + +function buildSigningString({ sessionId, timestamp, method, pathWithQuery }) { + return `${sessionId}\n${timestamp}\n${method.toUpperCase()}\n${pathWithQuery}`; +} + +/** + * The HMAC key is the UTF-8 bytes of the hex access key string. + * + * @returns {Record} the three proof headers + */ +function signPreviewRequest({ sessionId, accessKey, timestamp, method, pathWithQuery }) { + const signature = crypto + .createHmac('sha256', accessKey) + .update(buildSigningString({ sessionId, timestamp, method, pathWithQuery })) + .digest('hex'); + return { + [SESSION_HEADER]: sessionId, + [TIMESTAMP_HEADER]: String(timestamp), + [SIGNATURE_HEADER]: signature, + }; +} + +function parseUrl(value) { + try { + return new URL(value); + } catch { + return null; + } +} + +function isLoopbackHost(host) { + const url = typeof host === 'string' && host ? parseUrl(`http://${host}`) : null; + return url !== null && LOOPBACK_HOSTNAMES.has(url.hostname); +} + +function isLoopbackUrl(value) { + const url = typeof value === 'string' ? parseUrl(value) : null; + return url !== null && LOOPBACK_HOSTNAMES.has(url.hostname); +} + +function isExpectedLocalOrigin(value, localPort) { + const url = typeof value === 'string' ? parseUrl(value) : null; + return ( + url !== null && + url.protocol === 'http:' && + LOOPBACK_HOSTNAMES.has(url.hostname) && + url.port === String(Number(localPort)) + ); +} + +/** + * Origin and Referer from the local front door point at the storefront instead. + * Other values do not change. + */ +function rewriteRequestHeaders(request, storefrontOrigin) { + const { origin, referer } = request.headers; + if (isLoopbackUrl(origin)) { + request.headers.origin = storefrontOrigin; + } + if (isLoopbackUrl(referer)) { + const { pathname, search } = new URL(referer); + request.headers.referer = `${storefrontOrigin}${pathname}${search}`; + } +} + +/** + * Keeps the browser on the local front door when the storefront redirects to itself. + * A redirect to another host does not change. + */ +function rewriteLocation(location, storefrontUrl, localOrigin) { + if (typeof location !== 'string' || !/^(https?:)?\/\//i.test(location)) { + return location; + } + let url; + try { + url = new URL(location, storefrontUrl); + } catch { + return location; + } + if (url.host !== storefrontUrl.host || !/^https?:$/.test(url.protocol)) { + return location; + } + return `${localOrigin}${url.pathname}${url.search}${url.hash}`; +} + +function sendText(response, status, message) { + response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' }); + response.end(message); +} + +/** + * Connect middleware for the local front door in Makeswift preview mode. It forwards each + * request to the storefront with a per-request proof, so the access key never leaves the CLI. + * + * @param {object} options + * @param {{ storeUrl: string, sessionId: string, accessKey: string }} options.previewSession - + * read on each request, so a re-registered session is used at once + * @param {number} options.localPort - BrowserSync port for this preview server + * @param {Function} [options.proxyFactory] + * @param {{ error: Function }} [options.logger] + * @param {() => number} [options.now] + * @returns {(request: import('http').IncomingMessage, response: import('http').ServerResponse) => void} + */ +function createPreviewProxy({ + previewSession, + localPort, + proxyFactory = httpProxy.createProxyServer, + logger = console, + now = Date.now, +}) { + const storefrontUrl = new URL(previewSession.storeUrl); + const storefrontOrigin = storefrontUrl.origin; + const proxy = proxyFactory({ + target: storefrontOrigin, + changeOrigin: true, + secure: true, + xfwd: false, + }); + + proxy.on('proxyReq', (proxyRequest, request) => { + // http-proxy collapses repeated slashes in the path. Send the exact signed path. + Object.assign(proxyRequest, { path: request.url }); + }); + + proxy.on('proxyRes', (proxyResponse, request) => { + const { headers } = proxyResponse; + if (headers.location) { + headers.location = rewriteLocation( + headers.location, + storefrontUrl, + `http://${request.headers.host}`, + ); + } + }); + + proxy.on('error', (error, _request, response) => { + logger.error(`Makeswift preview: the storefront request failed. ${error.message}`); + if (!response || typeof response.writeHead !== 'function') { + return; + } + if (response.headersSent) { + response.destroy(); + return; + } + sendText(response, 502, 'Makeswift preview: the storefront request failed.'); + }); + + return (request, response) => { + // Blocks DNS rebinding: another site cannot use the browser to send signed requests. + if (!isLoopbackHost(request.headers.host)) { + sendText(response, 403, 'Makeswift preview is available only on localhost.'); + return; + } + if ( + request.headers.origin !== undefined && + !isExpectedLocalOrigin(request.headers.origin, localPort) + ) { + sendText(response, 403, 'Makeswift preview requests must come from this server.'); + return; + } + const pathWithQuery = getSignedPath(request.url, storefrontOrigin); + if (pathWithQuery === null) { + sendText(response, 400, 'Bad request.'); + return; + } + + for (const name of Object.keys(request.headers)) { + if (name.startsWith(PREVIEW_HEADER_PREFIX)) { + delete request.headers[name]; + } + } + delete request.headers.expect; + rewriteRequestHeaders(request, storefrontOrigin); + Object.assign( + request.headers, + signPreviewRequest({ + sessionId: previewSession.sessionId, + accessKey: previewSession.accessKey, + timestamp: now(), + method: request.method, + pathWithQuery, + }), + ); + request.url = pathWithQuery; + + proxy.web(request, response); + }; +} + +export { + buildSigningString, + createPreviewProxy, + getSignedPath, + isLoopbackHost, + rewriteLocation, + signPreviewRequest, +}; diff --git a/lib/preview-proxy.spec.js b/lib/preview-proxy.spec.js new file mode 100644 index 00000000..afe8dc43 --- /dev/null +++ b/lib/preview-proxy.spec.js @@ -0,0 +1,183 @@ +import { jest } from '@jest/globals'; +import http from 'http'; +import { webcrypto } from 'crypto'; +import { createPreviewProxy } from './preview-proxy.js'; + +const sessionId = 'session-1'; +const accessKey = 'a'.repeat(64); +const timestamp = 1791400000000; + +// Same steps as verifySignature() in makeswift-stencil-worker verify-preview-proof.ts. +async function workerVerifies(signature, signingString) { + const encoder = new TextEncoder(); + const key = await webcrypto.subtle.importKey( + 'raw', + encoder.encode(accessKey), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'], + ); + return webcrypto.subtle.verify( + 'HMAC', + key, + Buffer.from(signature, 'hex'), + encoder.encode(signingString), + ); +} + +function listen(handler) { + const server = http.createServer(handler); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve(server)); + }); +} + +function close(server) { + server.closeAllConnections(); + return new Promise((resolve) => { + server.close(resolve); + }); +} + +function startFrontDoor(storeUrl) { + const server = http.createServer(); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + server.on( + 'request', + createPreviewProxy({ + previewSession: { storeUrl, sessionId, accessKey }, + localPort: server.address().port, + logger: { error: jest.fn() }, + now: () => timestamp, + }), + ); + resolve(server); + }); + }); +} + +function send(server, { method = 'GET', path = '/', headers = {}, body } = {}) { + return new Promise((resolve, reject) => { + const request = http.request( + { host: '127.0.0.1', port: server.address().port, method, path, headers }, + (response) => { + response.resume(); + response.on('end', () => resolve(response)); + }, + ); + request.on('error', reject); + request.end(body); + }); +} + +describe('createPreviewProxy', () => { + let store; + let storeUrl; + let frontDoor; + let received; + + beforeAll(async () => { + store = await listen((request, response) => { + let body = ''; + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => { + received = { + method: request.method, + url: request.url, + headers: request.headers, + body, + }; + if (request.url === '/login.php') { + response.writeHead(302, { location: `${storeUrl}/account.php` }); + } + response.end(); + }); + }); + storeUrl = `http://127.0.0.1:${store.address().port}`; + frontDoor = await startFrontDoor(storeUrl); + }); + + beforeEach(() => { + received = null; + }); + + afterAll(async () => { + await close(frontDoor); + await close(store); + }); + + it('should forward a signed request that the worker accepts', async () => { + await send(frontDoor, { + method: 'POST', + path: '/cart.php?action=add', + body: 'qty=2', + headers: { cookie: 'SHOP_SESSION_TOKEN=abc', 'x-stencil-preview-secret': 'leaked' }, + }); + + expect(received).toMatchObject({ + method: 'POST', + url: '/cart.php?action=add', + body: 'qty=2', + }); + expect(received.headers.cookie).toBe('SHOP_SESSION_TOKEN=abc'); + expect(received.headers['x-stencil-preview-secret']).toBeUndefined(); + await expect( + workerVerifies( + received.headers['x-stencil-preview-signature'], + `${sessionId}\n${timestamp}\nPOST\n/cart.php?action=add`, + ), + ).resolves.toBe(true); + }); + + it('should point this server Origin and Referer at the storefront', async () => { + const local = `http://127.0.0.1:${frontDoor.address().port}`; + await send(frontDoor, { + method: 'POST', + path: '/login.php', + headers: { origin: local, referer: `${local}/login.php?from=cart` }, + }); + + expect(received.headers.origin).toBe(storeUrl); + expect(received.headers.referer).toBe(`${storeUrl}/login.php?from=cart`); + }); + + it('should reject an Origin from a different localhost port', async () => { + const otherLocal = `http://localhost:${frontDoor.address().port + 1}`; + const response = await send(frontDoor, { + headers: { origin: otherLocal }, + }); + + expect(response.statusCode).toBe(403); + expect(received).toBeNull(); + }); + + it('should keep storefront redirects on localhost', async () => { + const response = await send(frontDoor, { path: '/login.php' }); + + expect(response.headers.location).toBe( + `http://127.0.0.1:${frontDoor.address().port}/account.php`, + ); + }); + + it('should reject a request whose Host is not localhost', async () => { + const response = await send(frontDoor, { headers: { host: 'evil.example' } }); + + expect(response.statusCode).toBe(403); + expect(received).toBeNull(); + }); + + it('should return 502 when the storefront is down', async () => { + const down = await listen(); + const downUrl = `http://127.0.0.1:${down.address().port}`; + await close(down); + const brokenFrontDoor = await startFrontDoor(downUrl); + + const response = await send(brokenFrontDoor); + + expect(response.statusCode).toBe(502); + await close(brokenFrontDoor); + }); +}); diff --git a/lib/stencil-start.js b/lib/stencil-start.js index 61a8234c..5aa196eb 100755 --- a/lib/stencil-start.js +++ b/lib/stencil-start.js @@ -19,6 +19,9 @@ import LangValidator from './lang/validator.js'; import makeswiftProxyHealthModule from './makeswift-proxy-health.js'; import tunnelManagerModule from './TunnelManager.js'; import previewSessionClientModule from './PreviewSessionClient.js'; +import { createPreviewProxy } from './preview-proxy.js'; + +const PREVIEW_LISTEN_HOST = 'localhost'; class StencilStart { constructor({ @@ -39,6 +42,7 @@ class StencilStart { makeswiftProxyHealth = makeswiftProxyHealthModule, tunnelManager = tunnelManagerModule, previewSessionClient = previewSessionClientModule, + previewProxyFactory = createPreviewProxy, processObj = process, } = {}) { this._browserSync = browserSync; @@ -58,6 +62,7 @@ class StencilStart { this._makeswiftProxyHealth = makeswiftProxyHealth; this._tunnelManager = tunnelManager; this._previewSessionClient = previewSessionClient; + this._previewProxyFactory = previewProxyFactory; this._process = processObj; this._tunnel = null; this._previewSession = null; @@ -93,6 +98,7 @@ class StencilStart { } if (makeswiftPreview) { await this.startMakeswiftPreview({ + localPort: browserSyncPort, rendererPort: this.getRendererPort(browserSyncPort), storeUrl: previewStoreUrl, accessToken: updatedStencilConfig.accessToken, @@ -142,12 +148,13 @@ class StencilStart { * the preview session. If either fails, `stencil start` continues without Makeswift preview. * * @param {object} options + * @param {number} options.localPort - BrowserSync port, the local front door * @param {number} options.rendererPort * @param {string} options.storeUrl * @param {string} options.accessToken * @param {string} options.secret */ - async startMakeswiftPreview({ rendererPort, storeUrl, accessToken, secret }) { + async startMakeswiftPreview({ localPort, rendererPort, storeUrl, accessToken, secret }) { this._logger.log('Makeswift is enabled for this storefront. Starting Makeswift preview...'); try { this._tunnel = await this._tunnelManager.start(rendererPort); @@ -170,8 +177,9 @@ class StencilStart { this.warnPreviewUnavailable(err); return; } + const localUrl = `http://${PREVIEW_LISTEN_HOST}:${localPort}`; this._logger.log( - `Makeswift preview URL: ${this._tunnel.url.cyan}\n` + + `Makeswift preview is active. Open ${localUrl.cyan}\n` + 'The Makeswift preview session ends when you stop stencil start.', ); } @@ -353,8 +361,9 @@ class StencilStart { ignoreInitial: true, ignored: watchIgnored.map((val) => path.join(themePath, val)), }, - proxy: `localhost:${this.getRendererPort(browserSyncPort)}`, - tunnel, + ...(this._previewSession + ? this.getPreviewBrowserSyncOptions(browserSyncPort) + : { proxy: `localhost:${this.getRendererPort(browserSyncPort)}`, tunnel }), }); // Handle manual reloading of browsers by typing 'rs'; // Borrowed from https://github.com/remy/nodemon @@ -373,6 +382,31 @@ class StencilStart { await this.checkLangFiles(langsPath, this._storeSettingsLocale.default_shopper_language); } + /** + * In preview mode the local front door proxies to the storefront (not the renderer) and + * signs each request. The worker sends the request back to the renderer through the tunnel. + * It listens on localhost (a loopback address) only, so other devices cannot send signed + * requests. + * + * @returns {object} BrowserSync options + */ + getPreviewBrowserSyncOptions(localPort) { + return { + listen: PREVIEW_LISTEN_HOST, + // No static files. BrowserSync's own routes and HTML snippet injection still run + // first; the proxy middleware handles all other requests. + server: { baseDir: [] }, + middleware: [ + this._previewProxyFactory({ + previewSession: this._previewSession, + localPort, + logger: this._logger, + }), + ], + tunnel: false, + }; + } + /** * Assembles all the needed templates and resolves their partials. * diff --git a/lib/stencil-start.spec.js b/lib/stencil-start.spec.js index e34f8a6a..55fc09d0 100644 --- a/lib/stencil-start.spec.js +++ b/lib/stencil-start.spec.js @@ -87,6 +87,7 @@ describe('StencilStart unit tests', () => { makeswiftProxyHealth, tunnelManager, previewSessionClient, + previewProxyFactory, processObj, } = {}) => { const passedArgs = { @@ -105,6 +106,7 @@ describe('StencilStart unit tests', () => { makeswiftProxyHealth: makeswiftProxyHealth || getMakeswiftProxyHealthStub(), tunnelManager: tunnelManager || getTunnelManagerStub(), previewSessionClient: previewSessionClient || getPreviewSessionClientStub(), + previewProxyFactory: previewProxyFactory || jest.fn().mockReturnValue(jest.fn()), processObj: processObj || getProcessStub(), }; const instance = new StencilStart(passedArgs); @@ -203,6 +205,7 @@ describe('StencilStart unit tests', () => { describe('Makeswift preview', () => { const port = 3000; + const previewProxyMiddleware = jest.fn(); const runStart = async ({ cliOptions = {}, makeswiftActive = true, @@ -217,6 +220,7 @@ describe('StencilStart unit tests', () => { makeswiftProxyHealth: getMakeswiftProxyHealthStub(makeswiftActive), tunnelManager: tunnelManager || getTunnelManagerStub(), previewSessionClient: previewSessionClient || getPreviewSessionClientStub(), + previewProxyFactory: jest.fn().mockReturnValue(previewProxyMiddleware), stencilConfigManager: getStencilConfigManagerStub({ port, accessToken }), }; const { instance } = createStencilStartInstance(deps); @@ -232,30 +236,39 @@ describe('StencilStart unit tests', () => { const signalHandler = (processObj, signal) => processObj.on.mock.calls.find(([name]) => name === signal)[1]; - it('should start a tunnel to the renderer port and keep --open on localhost', async () => { - const { makeswiftProxyHealth, tunnelManager, browserSync, output } = await runStart({ - cliOptions: { open: true }, - }); + it('should start a tunnel to the renderer port and print the local front door', async () => { + const { makeswiftProxyHealth, tunnelManager, output } = await runStart(); expect(makeswiftProxyHealth.isMakeswiftProxyActive).toHaveBeenCalledWith({ storeUrl: 'https://www.example.com', }); expect(tunnelManager.start).toHaveBeenCalledWith(port + 1); - expect(output).toContain('https://preview-123.trycloudflare.com'); + expect(output).toContain(`http://localhost:${port}`); + expect(output).not.toContain('https://preview-123.trycloudflare.com'); expect(output).not.toContain(previewSecret); expect(output).not.toContain(registeredSession.accessKey); - expect(browserSync.init).toHaveBeenCalledWith( + }); + + it('should serve the signing proxy on localhost instead of proxying the renderer', async () => { + const { browserSync, previewProxyFactory, instance } = await runStart(); + expect(previewProxyFactory).toHaveBeenCalledWith( expect.objectContaining({ - open: true, - proxy: `localhost:${port + 1}`, - tunnel: false, + previewSession: instance.previewSession, + localPort: port, }), ); + const options = browserSync.init.mock.calls[0][0]; + expect(options).toMatchObject({ + listen: 'localhost', + middleware: [previewProxyMiddleware], + }); + expect(options).not.toHaveProperty('proxy'); }); it('should not start a tunnel or print anything when Makeswift is not active', async () => { - const { tunnelManager, processObj, output } = await runStart({ + const { tunnelManager, processObj, browserSync, output } = await runStart({ makeswiftActive: false, }); + expect(browserSync.init.mock.calls[0][0].proxy).toBe(`localhost:${port + 1}`); expect(tunnelManager.start).not.toHaveBeenCalled(); expect(processObj.on).not.toHaveBeenCalled(); expect(output).not.toContain('Makeswift'); diff --git a/package-lock.json b/package-lock.json index 813022ac..cd62df10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "front-matter": "^4.0.2", "glob": "^7.1.6", "graceful-fs": "^4.2.4", + "http-proxy": "^1.18.1", "husky": "^8.0.1", "image-size": "^0.9.1", "inquirer": "^8.1.5", diff --git a/package.json b/package.json index 4052b7a1..a0c9012e 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "front-matter": "^4.0.2", "glob": "^7.1.6", "graceful-fs": "^4.2.4", + "http-proxy": "^1.18.1", "husky": "^8.0.1", "image-size": "^0.9.1", "inquirer": "^8.1.5",