diff --git a/lib/PreviewSessionClient.js b/lib/PreviewSessionClient.js index 7335e9f6..1cf62660 100644 --- a/lib/PreviewSessionClient.js +++ b/lib/PreviewSessionClient.js @@ -1,6 +1,6 @@ import crypto from 'crypto'; -const REGISTER_PATH = '/api/makeswift/preview-sessions'; +const SESSIONS_PATH = '/api/makeswift/preview-sessions'; const ACCESS_TOKEN_HEADER = 'x-stencil-access-token'; const REQUEST_TIMEOUT_MS = 10000; @@ -15,17 +15,18 @@ function previewSessionError(message, { status, retryable = false } = {}) { const isNonEmptyString = (value) => typeof value === 'string' && value !== ''; +const isValidExpiresIn = (data) => Number.isFinite(data?.expiresIn) && data.expiresIn > 0; + function isValidRegistration(data) { return ( isNonEmptyString(data?.sessionId) && isNonEmptyString(data?.accessKey) && - Number.isFinite(data?.expiresIn) && - data.expiresIn > 0 + isValidExpiresIn(data) ); } /** - * Registers a Makeswift preview session. + * Registers and renews Makeswift preview sessions. */ class PreviewSessionClient { constructor({ fetch = globalThis.fetch, timeout = REQUEST_TIMEOUT_MS } = {}) { @@ -51,49 +52,78 @@ class PreviewSessionClient { * @returns {Promise<{ sessionId: string, accessKey: string, expiresIn: number }>} */ async register({ storeUrl, accessToken, tunnelUrl, secret }) { - const url = new URL(REGISTER_PATH, storeUrl); + const data = await this._post({ + storeUrl, + path: SESSIONS_PATH, + accessToken, + body: { tunnelUrl, secret }, + action: 'register', + isValid: isValidRegistration, + }); + const { sessionId, accessKey, expiresIn } = data; + return { sessionId, accessKey, expiresIn }; + } + + /** + * Renews the session TTL. The access key does not change. + * A 404 error means the session is gone and must be registered again. + * + * @param {object} options + * @param {string} options.storeUrl + * @param {string} options.accessToken + * @param {string} options.sessionId + * @returns {Promise<{ expiresIn: number }>} + */ + async heartbeat({ storeUrl, accessToken, sessionId }) { + const { expiresIn } = await this._post({ + storeUrl, + path: `${SESSIONS_PATH}/${encodeURIComponent(sessionId)}/heartbeat`, + accessToken, + action: 'renew', + isValid: isValidExpiresIn, + }); + return { expiresIn }; + } + + async _post({ storeUrl, path, accessToken, body, action, isValid }) { + const url = new URL(path, storeUrl); if (url.protocol !== 'https:') { throw previewSessionError('Makeswift preview requires an HTTPS store URL.'); } + const failure = `Could not ${action} the Makeswift preview session`; let response; try { response = await this._fetch(url, { method: 'POST', headers: { - 'content-type': 'application/json', + ...(body && { 'content-type': 'application/json' }), [ACCESS_TOKEN_HEADER]: accessToken, }, - body: JSON.stringify({ tunnelUrl, secret }), + body: body && JSON.stringify(body), redirect: 'manual', signal: AbortSignal.timeout(this._timeout), }); } catch { - throw previewSessionError( - 'Could not register the Makeswift preview session: the request failed.', - { retryable: true }, - ); + throw previewSessionError(`${failure}: the request failed.`, { retryable: true }); } const { status } = response; if (!response.ok) { const hint = status === 401 ? ' Make sure the access token in .stencil is valid.' : ''; - throw previewSessionError( - `Could not register the Makeswift preview session (HTTP ${status}).${hint}`, - { status, retryable: status === 429 || status >= 500 }, - ); + throw previewSessionError(`${failure} (HTTP ${status}).${hint}`, { + status, + retryable: status === 429 || status >= 500, + }); } const data = await response.json().catch(() => null); - if (!isValidRegistration(data)) { - throw previewSessionError( - `Could not register the Makeswift preview session: the response (HTTP ${status}) is not valid.`, - { status }, - ); + if (!isValid(data)) { + throw previewSessionError(`${failure}: the response (HTTP ${status}) is not valid.`, { + status, + }); } - - const { sessionId, accessKey, expiresIn } = data; - return { sessionId, accessKey, expiresIn }; + return data; } } diff --git a/lib/PreviewSessionClient.spec.js b/lib/PreviewSessionClient.spec.js index 3057d2fb..ff0f3a62 100644 --- a/lib/PreviewSessionClient.spec.js +++ b/lib/PreviewSessionClient.spec.js @@ -80,3 +80,45 @@ describe('PreviewSessionClient.register', () => { await expect(client.register(options)).rejects.toMatchObject({ retryable: true }); }); }); + +describe('PreviewSessionClient.heartbeat', () => { + const options = { + storeUrl: 'https://store.example.com', + accessToken: 'token-123', + sessionId: 'a/b', + }; + const respond = (status, body) => + jest.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })); + + it('renews the session with the access token', async () => { + const fetch = respond(200, { expiresIn: 300 }); + const client = new PreviewSessionClient({ fetch }); + + await expect(client.heartbeat(options)).resolves.toEqual({ expiresIn: 300 }); + + const [url, init] = fetch.mock.calls[0]; + expect(String(url)).toBe( + 'https://store.example.com/api/makeswift/preview-sessions/a%2Fb/heartbeat', + ); + expect(init).toMatchObject({ + method: 'POST', + headers: { 'x-stencil-access-token': 'token-123' }, + redirect: 'manual', + }); + }); + + it.each([ + [404, { error: 'Not found' }, false], + [401, { error: 'x' }, false], + [503, { error: 'x' }, true], + [200, {}, false], + ])('rejects HTTP %s with the status', async (status, body, retryable) => { + const client = new PreviewSessionClient({ fetch: respond(status, body) }); + + await expect(client.heartbeat(options)).rejects.toMatchObject({ + message: expect.stringContaining('Could not renew'), + status, + retryable, + }); + }); +}); diff --git a/lib/PreviewSessionHeartbeat.js b/lib/PreviewSessionHeartbeat.js new file mode 100644 index 00000000..403dbae2 --- /dev/null +++ b/lib/PreviewSessionHeartbeat.js @@ -0,0 +1,135 @@ +import 'colors'; + +const MIN_DELAY_MS = 30000; +const RENEW_MARGIN_MS = 60000; +const RETRY_DELAYS_MS = [15000, 30000, 60000]; +const WARN_AFTER_FAILURES = 2; + +/** + * Keeps a Makeswift preview session alive. When the session is gone (404), it registers a new + * one and updates the live session object, so the preview proxy uses it on the next request. + */ +class PreviewSessionHeartbeat { + /** + * @param {object} options + * @param {{ heartbeat: Function, register: Function }} options.client - PreviewSessionClient + * @param {{ storeUrl: string, tunnelUrl: string, secret: string, sessionId: string, accessKey: string, expiresIn: number }} options.session - + * the live session object; changed in place + * @param {string} options.accessToken + * @param {{ log: Function, error: Function }} [options.logger] + */ + constructor({ client, session, accessToken, logger = console }) { + this._client = client; + this._session = session; + this._accessToken = accessToken; + this._logger = logger; + this._timer = null; + this._stopped = true; + this._failures = 0; + this._needsRegister = false; + } + + start() { + this.stop(); + this._stopped = false; + this._schedule(this._renewDelay()); + } + + stop() { + this._stopped = true; + clearTimeout(this._timer); + this._timer = null; + } + + _renewDelay() { + return Math.max(MIN_DELAY_MS, this._session.expiresIn * 1000 - RENEW_MARGIN_MS); + } + + _schedule(delay) { + this._timer = setTimeout(() => this._beat(), delay); + this._timer.unref(); + } + + async _beat() { + this._timer = null; + try { + await (this._needsRegister ? this._register() : this._renew()); + } catch (err) { + if (!this._stopped) { + this._onFailure(err); + } + return; + } + if (this._stopped) { + return; + } + if (this._failures >= WARN_AFTER_FAILURES) { + this._logger.log('The Makeswift preview session is renewed.'); + } + this._failures = 0; + this._schedule(this._renewDelay()); + } + + async _renew() { + const { storeUrl, sessionId } = this._session; + let result; + try { + result = await this._client.heartbeat({ + storeUrl, + accessToken: this._accessToken, + sessionId, + }); + } catch (err) { + if (err.status !== 404) { + throw err; + } + this._needsRegister = true; + await this._register(); + return; + } + if (!this._stopped) { + this._session.expiresIn = result.expiresIn; + } + } + + async _register() { + if (this._stopped) { + return; + } + const { storeUrl, tunnelUrl, secret } = this._session; + const { sessionId, accessKey, expiresIn } = await this._client.register({ + storeUrl, + accessToken: this._accessToken, + tunnelUrl, + secret, + }); + if (this._stopped) { + return; + } + Object.assign(this._session, { sessionId, accessKey, expiresIn }); + this._needsRegister = false; + this._logger.log('The Makeswift preview session expired. A new session is registered.'); + } + + _onFailure(err) { + if (!err.retryable) { + this._logger.error( + `${'Error'.red}: ${err.message}\n` + + 'Makeswift preview stops when the session expires. ' + + 'Correct the problem, then restart stencil start.', + ); + return; + } + this._failures += 1; + const delay = RETRY_DELAYS_MS[Math.min(this._failures, RETRY_DELAYS_MS.length) - 1]; + if (this._failures === WARN_AFTER_FAILURES) { + this._logger.error( + `${'Warning'.yellow}: ${err.message} Retrying in ${delay / 1000}s.\n` + + 'Makeswift preview can stop working until the session is renewed.', + ); + } + this._schedule(delay); + } +} + +export default PreviewSessionHeartbeat; diff --git a/lib/PreviewSessionHeartbeat.spec.js b/lib/PreviewSessionHeartbeat.spec.js new file mode 100644 index 00000000..6cfdfd1d --- /dev/null +++ b/lib/PreviewSessionHeartbeat.spec.js @@ -0,0 +1,129 @@ +import { jest } from '@jest/globals'; +import PreviewSessionHeartbeat from './PreviewSessionHeartbeat.js'; + +const SECOND = 1000; +const error = (status, retryable = false) => + Object.assign(new Error(`HTTP ${status}`), { status, retryable }); + +describe('PreviewSessionHeartbeat', () => { + let session; + let client; + let logger; + let heartbeat; + + beforeEach(() => { + jest.useFakeTimers(); + session = { + storeUrl: 'https://store.example.com', + tunnelUrl: 'https://preview.trycloudflare.com', + secret: 's'.repeat(64), + sessionId: 'session-1', + accessKey: 'key-1', + expiresIn: 300, + }; + client = { + heartbeat: jest.fn().mockResolvedValue({ expiresIn: 300 }), + register: jest + .fn() + .mockResolvedValue({ sessionId: 'session-2', accessKey: 'key-2', expiresIn: 300 }), + }; + logger = { log: jest.fn(), error: jest.fn() }; + heartbeat = new PreviewSessionHeartbeat({ client, session, accessToken: 'token', logger }); + heartbeat.start(); + }); + + afterEach(() => { + heartbeat.stop(); + jest.useRealTimers(); + }); + + it('renews 60s before the TTL ends, so the session survives past its TTL', async () => { + await jest.advanceTimersByTimeAsync(239 * SECOND); + expect(client.heartbeat).not.toHaveBeenCalled(); + + // 720s: past two TTLs. + await jest.advanceTimersByTimeAsync(481 * SECOND); + expect(client.heartbeat).toHaveBeenCalledTimes(3); + expect(client.heartbeat).toHaveBeenCalledWith({ + storeUrl: session.storeUrl, + accessToken: 'token', + sessionId: 'session-1', + }); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('registers again with the same tunnel and secret when the session is gone', async () => { + client.heartbeat.mockRejectedValueOnce(error(404)); + + await jest.advanceTimersByTimeAsync(240 * SECOND); + expect(client.register).toHaveBeenCalledWith({ + storeUrl: session.storeUrl, + accessToken: 'token', + tunnelUrl: session.tunnelUrl, + secret: session.secret, + }); + expect(session).toMatchObject({ sessionId: 'session-2', accessKey: 'key-2' }); + + await jest.advanceTimersByTimeAsync(240 * SECOND); + expect(client.heartbeat).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: 'session-2' }), + ); + }); + + it('backs off on repeated failures, warns once, and reports recovery', async () => { + client.heartbeat + .mockRejectedValueOnce(error(503, true)) + .mockRejectedValueOnce(error(503, true)) + .mockRejectedValueOnce(error(503, true)); + + await jest.advanceTimersByTimeAsync(240 * SECOND); + expect(logger.error).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(15 * SECOND); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0][0]).toContain('HTTP 503 Retrying in 30s.'); + await jest.advanceTimersByTimeAsync(30 * SECOND); + expect(client.heartbeat).toHaveBeenCalledTimes(3); + await jest.advanceTimersByTimeAsync(59 * SECOND); + expect(client.heartbeat).toHaveBeenCalledTimes(3); + await jest.advanceTimersByTimeAsync(1 * SECOND); + expect(client.heartbeat).toHaveBeenCalledTimes(4); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.log).toHaveBeenCalledWith('The Makeswift preview session is renewed.'); + }); + + it('retries a failed registration without another heartbeat', async () => { + client.heartbeat.mockRejectedValueOnce(error(404)); + client.register.mockRejectedValueOnce(error(503, true)); + + await jest.advanceTimersByTimeAsync((240 + 15) * SECOND); + expect(client.heartbeat).toHaveBeenCalledTimes(1); + expect(client.register).toHaveBeenCalledTimes(2); + expect(session.sessionId).toBe('session-2'); + }); + + it('shows the error and stops when the failure is not retryable', async () => { + client.heartbeat.mockRejectedValueOnce(error(401)); + + await jest.advanceTimersByTimeAsync(240 * SECOND); + expect(logger.error.mock.calls[0][0]).toContain('HTTP 401'); + expect(jest.getTimerCount()).toBe(0); + }); + + it('does not register or schedule after stop() during a request', async () => { + let reject; + client.heartbeat.mockReturnValueOnce( + new Promise((_, rej) => { + reject = rej; + }), + ); + + await jest.advanceTimersByTimeAsync(240 * SECOND); + heartbeat.stop(); + reject(error(404)); + await jest.runAllTimersAsync(); + + expect(client.register).not.toHaveBeenCalled(); + expect(session.sessionId).toBe('session-1'); + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/lib/preview-proxy.js b/lib/preview-proxy.js new file mode 100644 index 00000000..70123dac --- /dev/null +++ b/lib/preview-proxy.js @@ -0,0 +1,205 @@ +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); +} + +/** + * 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 {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, + 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; + } + 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..4d0d7389 --- /dev/null +++ b/lib/preview-proxy.spec.js @@ -0,0 +1,443 @@ +import { jest } from '@jest/globals'; +import http from 'http'; +import { webcrypto } from 'crypto'; +import { + buildSigningString, + createPreviewProxy, + getSignedPath, + isLoopbackHost, + rewriteLocation, + signPreviewRequest, +} from './preview-proxy.js'; + +const sessionId = '0b6f9c3e-4d2a-4f7b-9a1c-2e3d4f5a6b7c'; +const accessKey = '0123456789abcdef'.repeat(4); +const timestamp = 1791400000000; +const storeOrigin = 'https://store.example.com'; + +// Same steps as verifySignature() in makeswift-stencil-worker verify-preview-proof.ts. +async function workerVerifies({ signature, signingString, key = accessKey }) { + const cryptoKey = await webcrypto.subtle.importKey( + 'raw', + new TextEncoder().encode(key), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'], + ); + return webcrypto.subtle.verify( + 'HMAC', + cryptoKey, + Buffer.from(signature, 'hex'), + new TextEncoder().encode(signingString), + ); +} + +describe('preview-proxy', () => { + describe('buildSigningString', () => { + it('should join session, timestamp, method, and path with newlines', () => { + expect( + buildSigningString({ + sessionId, + timestamp, + method: 'post', + pathWithQuery: '/cart.php?action=add&product_id=1', + }), + ).toBe( + '0b6f9c3e-4d2a-4f7b-9a1c-2e3d4f5a6b7c\n1791400000000\nPOST\n/cart.php?action=add&product_id=1', + ); + }); + }); + + describe('signPreviewRequest', () => { + const pathWithQuery = '/cart.php?action=add&product_id=1'; + + it('should return the proof headers with a pinned signature', () => { + expect( + signPreviewRequest({ + sessionId, + accessKey, + timestamp, + method: 'POST', + pathWithQuery, + }), + ).toEqual({ + 'x-stencil-preview-session': sessionId, + 'x-stencil-preview-timestamp': '1791400000000', + 'x-stencil-preview-signature': + '1faf01f8cb54eebd4a3613f82e788c1adb608dee6db5b5bb97039c32682533bf', + }); + }); + + it('should make a signature that the worker accepts', async () => { + const headers = signPreviewRequest({ + sessionId, + accessKey, + timestamp, + method: 'GET', + pathWithQuery: '/products/a%20b/?sort=new', + }); + const signature = headers['x-stencil-preview-signature']; + const signingString = `${sessionId}\n${timestamp}\nGET\n/products/a%20b/?sort=new`; + + expect(signature).toMatch(/^[a-f0-9]{64}$/); + await expect(workerVerifies({ signature, signingString })).resolves.toBe(true); + await expect( + workerVerifies({ signature, signingString: signingString.replace('GET', 'POST') }), + ).resolves.toBe(false); + await expect( + workerVerifies({ signature, signingString, key: 'f'.repeat(64) }), + ).resolves.toBe(false); + }); + }); + + describe('getSignedPath', () => { + it.each([ + ['/', '/'], + ['/cart.php?action=add', '/cart.php?action=add'], + ['/a/../b/./c?x=1#frag', '/b/c?x=1'], + ['//evil.example/x', '//evil.example/x'], + ['/a//b?q=a//b', '/a//b?q=a//b'], + ['/é?q=é', '/%C3%A9?q=%C3%A9'], + ['/%7Efoo?q=%20', '/%7Efoo?q=%20'], + ['/search?', '/search'], + ])('should normalize %s to %s', (rawUrl, expected) => { + expect(getSignedPath(rawUrl, storeOrigin)).toBe(expected); + }); + + it.each(['https://other.example/x', '*', '', undefined])('should reject %s', (rawUrl) => { + expect(getSignedPath(rawUrl, storeOrigin)).toBeNull(); + }); + }); + + describe('isLoopbackHost', () => { + it.each([ + ['localhost:3000', true], + ['127.0.0.1:3000', true], + ['[::1]:3000', true], + ['localhost', true], + ['evil.example:3000', false], + ['localhost.evil.example', false], + ['', false], + [undefined, false], + ])('%s -> %s', (host, expected) => { + expect(isLoopbackHost(host)).toBe(expected); + }); + }); + + describe('rewriteLocation', () => { + const storefrontUrl = new URL(storeOrigin); + const localOrigin = 'http://127.0.0.1:3000'; + + it.each([ + [ + 'https://store.example.com/login.php?from=x#top', + `${localOrigin}/login.php?from=x#top`, + ], + ['http://store.example.com/cart.php', `${localOrigin}/cart.php`], + ['//store.example.com/account.php', `${localOrigin}/account.php`], + ['https://other.example.com/x', 'https://other.example.com/x'], + ['https://store.example.com:8443/x', 'https://store.example.com:8443/x'], + ['/relative/path', '/relative/path'], + ])('%s -> %s', (location, expected) => { + expect(rewriteLocation(location, storefrontUrl, localOrigin)).toBe(expected); + }); + }); + + describe('createPreviewProxy', () => { + const getProxyStub = () => { + const handlers = {}; + return { + handlers, + on: jest.fn((event, handler) => { + handlers[event] = handler; + }), + web: jest.fn(), + }; + }; + const getResponseStub = () => ({ + writeHead: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + headersSent: false, + }); + const setup = () => { + const proxy = getProxyStub(); + const proxyFactory = jest.fn().mockReturnValue(proxy); + const logger = { error: jest.fn() }; + const previewSession = { storeUrl: `${storeOrigin}/`, sessionId, accessKey }; + const middleware = createPreviewProxy({ + previewSession, + proxyFactory, + logger, + now: () => timestamp, + }); + return { proxy, proxyFactory, logger, previewSession, middleware }; + }; + const getRequest = (overrides = {}) => ({ + method: 'GET', + url: '/', + ...overrides, + headers: { host: 'localhost:3000', ...overrides.headers }, + }); + + it('should proxy to the storefront origin with TLS verification', () => { + const { proxyFactory } = setup(); + expect(proxyFactory).toHaveBeenCalledWith({ + target: storeOrigin, + changeOrigin: true, + secure: true, + xfwd: false, + }); + }); + + it('should sign the normalized path and forward that path', () => { + const { proxy, middleware } = setup(); + const request = getRequest({ method: 'POST', url: '/a/../cart.php?x=1' }); + const response = getResponseStub(); + + middleware(request, response); + + expect(request.url).toBe('/cart.php?x=1'); + expect(request.headers).toEqual({ + host: 'localhost:3000', + ...signPreviewRequest({ + sessionId, + accessKey, + timestamp, + method: 'POST', + pathWithQuery: '/cart.php?x=1', + }), + }); + expect(proxy.web).toHaveBeenCalledWith(request, response); + + const proxyRequest = { path: '/collapsed' }; + proxy.handlers.proxyReq(proxyRequest, request); + expect(proxyRequest.path).toBe('/cart.php?x=1'); + }); + + it('should remove inbound preview headers and never send the secret', () => { + const { middleware } = setup(); + const request = getRequest({ + headers: { + 'x-stencil-preview-secret': 'leaked', + 'x-stencil-preview-session': 'forged', + 'x-stencil-preview-signature': 'forged', + 'x-stencil-preview-other': 'x', + }, + }); + + middleware(request, getResponseStub()); + + expect(request.headers['x-stencil-preview-secret']).toBeUndefined(); + expect(request.headers['x-stencil-preview-other']).toBeUndefined(); + expect(request.headers['x-stencil-preview-session']).toBe(sessionId); + expect(request.headers['x-stencil-preview-signature']).not.toBe('forged'); + }); + + it('should rewrite loopback Origin and Referer and keep cookies', () => { + const { middleware } = setup(); + const request = getRequest({ + headers: { + origin: 'http://localhost:3000', + referer: 'http://127.0.0.1:3000/cart.php?a=1', + cookie: 'SHOP_SESSION_TOKEN=abc; other=1', + }, + }); + + middleware(request, getResponseStub()); + + expect(request.headers.origin).toBe(storeOrigin); + expect(request.headers.referer).toBe(`${storeOrigin}/cart.php?a=1`); + expect(request.headers.cookie).toBe('SHOP_SESSION_TOKEN=abc; other=1'); + }); + + it('should keep a non-loopback Origin and Referer', () => { + const { middleware } = setup(); + const request = getRequest({ + headers: { origin: 'https://other.example', referer: 'https://other.example/p' }, + }); + + middleware(request, getResponseStub()); + + expect(request.headers.origin).toBe('https://other.example'); + expect(request.headers.referer).toBe('https://other.example/p'); + }); + + it('should use the current session values on each request', () => { + const { middleware, previewSession } = setup(); + previewSession.sessionId = 'new-session'; + previewSession.accessKey = 'f'.repeat(64); + const request = getRequest(); + + middleware(request, getResponseStub()); + + expect(request.headers).toEqual( + expect.objectContaining( + signPreviewRequest({ + sessionId: 'new-session', + accessKey: 'f'.repeat(64), + timestamp, + method: 'GET', + pathWithQuery: '/', + }), + ), + ); + }); + + it('should reject a request whose Host is not loopback', () => { + const { proxy, middleware } = setup(); + const response = getResponseStub(); + + middleware(getRequest({ headers: { host: 'evil.example:3000' } }), response); + + expect(response.writeHead).toHaveBeenCalledWith(403, expect.any(Object)); + expect(proxy.web).not.toHaveBeenCalled(); + }); + + it('should reject a request target that is not a path', () => { + const { proxy, middleware } = setup(); + const response = getResponseStub(); + + middleware(getRequest({ url: 'https://other.example/x' }), response); + + expect(response.writeHead).toHaveBeenCalledWith(400, expect.any(Object)); + expect(proxy.web).not.toHaveBeenCalled(); + }); + + it('should rewrite a same-store redirect to the local origin', () => { + const { proxy } = setup(); + const proxyResponse = { headers: { location: `${storeOrigin}/login.php` } }; + + proxy.handlers.proxyRes(proxyResponse, getRequest()); + + expect(proxyResponse.headers.location).toBe('http://localhost:3000/login.php'); + }); + + it('should return 502 when the storefront request fails', () => { + const { proxy, logger } = setup(); + const response = getResponseStub(); + + proxy.handlers.error(new Error('ECONNRESET'), getRequest(), response); + + expect(response.writeHead).toHaveBeenCalledWith(502, expect.any(Object)); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('ECONNRESET')); + }); + + it('should close the response when the error happens after the headers are sent', () => { + const { proxy } = setup(); + const response = { ...getResponseStub(), headersSent: true }; + + proxy.handlers.error(new Error('ECONNRESET'), getRequest(), response); + + expect(response.destroy).toHaveBeenCalled(); + expect(response.writeHead).not.toHaveBeenCalled(); + }); + }); + + describe('createPreviewProxy with a real upstream', () => { + let upstream; + let front; + let frontPort; + const received = []; + + beforeAll(async () => { + upstream = http.createServer((request, response) => { + let body = ''; + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => { + received.push({ + method: request.method, + url: request.url, + headers: request.headers, + body, + }); + response.end('ok'); + }); + }); + await new Promise((resolve) => { + upstream.listen(0, '127.0.0.1', resolve); + }); + const previewSession = { + storeUrl: `http://127.0.0.1:${upstream.address().port}`, + sessionId, + accessKey, + }; + const middleware = createPreviewProxy({ + previewSession, + logger: { error: jest.fn() }, + now: () => timestamp, + }); + front = http.createServer(middleware); + await new Promise((resolve) => { + front.listen(0, '127.0.0.1', resolve); + }); + frontPort = front.address().port; + }); + + afterAll(async () => { + front.closeAllConnections(); + await new Promise((resolve) => { + front.close(resolve); + }); + await new Promise((resolve) => { + upstream.close(resolve); + }); + }); + + it('should send the signed path, method, body, and proof headers upstream', async () => { + const response = await fetch(`http://127.0.0.1:${frontPort}//a//b?q=1`, { + method: 'POST', + body: 'qty=2', + headers: { cookie: 'c=1', 'x-stencil-preview-secret': 'leaked' }, + }); + + expect(await response.text()).toBe('ok'); + const [request] = received; + expect(request.method).toBe('POST'); + expect(request.url).toBe('//a//b?q=1'); + expect(request.body).toBe('qty=2'); + expect(request.headers.cookie).toBe('c=1'); + expect(request.headers['x-stencil-preview-secret']).toBeUndefined(); + expect(request.headers.host).toBe(`127.0.0.1:${upstream.address().port}`); + await expect( + workerVerifies({ + signature: request.headers['x-stencil-preview-signature'], + signingString: `${sessionId}\n${timestamp}\nPOST\n//a//b?q=1`, + }), + ).resolves.toBe(true); + }); + + it('should send the signed path when the client sends Expect: 100-continue', async () => { + received.length = 0; + const status = await new Promise((resolve, reject) => { + const request = http.request({ + host: '127.0.0.1', + port: frontPort, + method: 'PUT', + path: '//c//d', + headers: { expect: '100-continue', 'content-length': '4' }, + }); + request.on('continue', () => request.end('body')); + request.on('response', (response) => { + response.resume(); + response.on('end', () => resolve(response.statusCode)); + }); + request.on('error', reject); + }); + + expect(status).toBe(200); + const [request] = received; + expect(request.url).toBe('//c//d'); + expect(request.body).toBe('body'); + expect(request.headers.expect).toBeUndefined(); + await expect( + workerVerifies({ + signature: request.headers['x-stencil-preview-signature'], + signingString: `${sessionId}\n${timestamp}\nPUT\n//c//d`, + }), + ).resolves.toBe(true); + }); + }); +}); diff --git a/lib/stencil-start.js b/lib/stencil-start.js index 61a8234c..3de3bb44 100755 --- a/lib/stencil-start.js +++ b/lib/stencil-start.js @@ -19,6 +19,10 @@ import LangValidator from './lang/validator.js'; import makeswiftProxyHealthModule from './makeswift-proxy-health.js'; import tunnelManagerModule from './TunnelManager.js'; import previewSessionClientModule from './PreviewSessionClient.js'; +import PreviewSessionHeartbeat from './PreviewSessionHeartbeat.js'; +import { createPreviewProxy } from './preview-proxy.js'; + +const PREVIEW_LISTEN_HOST = 'localhost'; class StencilStart { constructor({ @@ -39,6 +43,8 @@ class StencilStart { makeswiftProxyHealth = makeswiftProxyHealthModule, tunnelManager = tunnelManagerModule, previewSessionClient = previewSessionClientModule, + previewProxyFactory = createPreviewProxy, + previewSessionHeartbeatFactory = (options) => new PreviewSessionHeartbeat(options), processObj = process, } = {}) { this._browserSync = browserSync; @@ -58,9 +64,12 @@ class StencilStart { this._makeswiftProxyHealth = makeswiftProxyHealth; this._tunnelManager = tunnelManager; this._previewSessionClient = previewSessionClient; + this._previewProxyFactory = previewProxyFactory; + this._previewSessionHeartbeatFactory = previewSessionHeartbeatFactory; this._process = processObj; this._tunnel = null; this._previewSession = null; + this._previewSessionHeartbeat = null; } async run(cliOptions) { @@ -93,6 +102,7 @@ class StencilStart { } if (makeswiftPreview) { await this.startMakeswiftPreview({ + localPort: browserSyncPort, rendererPort: this.getRendererPort(browserSyncPort), storeUrl: previewStoreUrl, accessToken: updatedStencilConfig.accessToken, @@ -142,12 +152,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); @@ -166,12 +177,20 @@ class StencilStart { }); this._previewSession = { storeUrl, tunnelUrl, secret, ...registration }; } catch (err) { - await this.stopMakeswiftPreviewTunnel(); + await this.stopMakeswiftPreview(); this.warnPreviewUnavailable(err); return; } + this._previewSessionHeartbeat = this._previewSessionHeartbeatFactory({ + client: this._previewSessionClient, + session: this._previewSession, + accessToken, + logger: this._logger, + }); + this._previewSessionHeartbeat.start(); + 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.', ); } @@ -192,7 +211,7 @@ class StencilStart { } shuttingDown = true; try { - await this.stopMakeswiftPreviewTunnel(); + await this.stopMakeswiftPreview(); } finally { this._process.exit(signal === 'SIGINT' ? 130 : 143); } @@ -200,10 +219,12 @@ class StencilStart { this._process.on('SIGINT', onSignal); this._process.on('SIGTERM', onSignal); // Other exits (for example process.exit after an error) must not leave cloudflared running. - this._process.once('exit', () => this.stopMakeswiftPreviewTunnel()); + this._process.once('exit', () => this.stopMakeswiftPreview()); } - stopMakeswiftPreviewTunnel() { + stopMakeswiftPreview() { + this._previewSessionHeartbeat?.stop(); + this._previewSessionHeartbeat = null; const tunnel = this._tunnel; this._tunnel = null; return tunnel ? tunnel.stop() : Promise.resolve(); @@ -353,8 +374,9 @@ class StencilStart { ignoreInitial: true, ignored: watchIgnored.map((val) => path.join(themePath, val)), }, - proxy: `localhost:${this.getRendererPort(browserSyncPort)}`, - tunnel, + ...(this._previewSession + ? this.getPreviewBrowserSyncOptions() + : { proxy: `localhost:${this.getRendererPort(browserSyncPort)}`, tunnel }), }); // Handle manual reloading of browsers by typing 'rs'; // Borrowed from https://github.com/remy/nodemon @@ -373,6 +395,30 @@ 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() { + 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, + 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..36b5e50a 100644 --- a/lib/stencil-start.spec.js +++ b/lib/stencil-start.spec.js @@ -63,6 +63,8 @@ describe('StencilStart unit tests', () => { createSecret: jest.fn().mockReturnValue(previewSecret), register: jest.fn().mockResolvedValue(registeredSession), }); + const getHeartbeatFactoryStub = () => + jest.fn().mockReturnValue({ start: jest.fn(), stop: jest.fn() }); const getProcessStub = () => ({ on: jest.fn(), once: jest.fn(), @@ -87,6 +89,8 @@ describe('StencilStart unit tests', () => { makeswiftProxyHealth, tunnelManager, previewSessionClient, + previewProxyFactory, + previewSessionHeartbeatFactory, processObj, } = {}) => { const passedArgs = { @@ -105,6 +109,9 @@ describe('StencilStart unit tests', () => { makeswiftProxyHealth: makeswiftProxyHealth || getMakeswiftProxyHealthStub(), tunnelManager: tunnelManager || getTunnelManagerStub(), previewSessionClient: previewSessionClient || getPreviewSessionClientStub(), + previewProxyFactory: previewProxyFactory || jest.fn().mockReturnValue(jest.fn()), + previewSessionHeartbeatFactory: + previewSessionHeartbeatFactory || getHeartbeatFactoryStub(), processObj: processObj || getProcessStub(), }; const instance = new StencilStart(passedArgs); @@ -203,6 +210,7 @@ describe('StencilStart unit tests', () => { describe('Makeswift preview', () => { const port = 3000; + const previewProxyMiddleware = jest.fn(); const runStart = async ({ cliOptions = {}, makeswiftActive = true, @@ -217,6 +225,8 @@ describe('StencilStart unit tests', () => { makeswiftProxyHealth: getMakeswiftProxyHealthStub(makeswiftActive), tunnelManager: tunnelManager || getTunnelManagerStub(), previewSessionClient: previewSessionClient || getPreviewSessionClientStub(), + previewProxyFactory: jest.fn().mockReturnValue(previewProxyMiddleware), + previewSessionHeartbeatFactory: getHeartbeatFactoryStub(), stencilConfigManager: getStencilConfigManagerStub({ port, accessToken }), }; const { instance } = createStencilStartInstance(deps); @@ -232,24 +242,52 @@ 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('Makeswift preview is active. Open'); + 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 a signing proxy to the storefront on loopback only', async () => { + const { browserSync, previewProxyFactory, logger, instance } = await runStart({ + cliOptions: { open: true }, + }); + expect(previewProxyFactory).toHaveBeenCalledWith({ + previewSession: instance.previewSession, + logger, + }); + const options = browserSync.init.mock.calls[0][0]; + expect(options).toEqual( expect.objectContaining({ open: true, - proxy: `localhost:${port + 1}`, + port, + listen: 'localhost', + server: { baseDir: [] }, + middleware: [previewProxyMiddleware], tunnel: false, }), ); + expect(options).not.toHaveProperty('proxy'); + }); + + it('should proxy BrowserSync to the renderer when preview is off', async () => { + const { browserSync, previewProxyFactory } = await runStart({ + makeswiftActive: false, + }); + expect(previewProxyFactory).not.toHaveBeenCalled(); + const options = browserSync.init.mock.calls[0][0]; + expect(options).toEqual( + expect.objectContaining({ proxy: `localhost:${port + 1}`, tunnel: false }), + ); + expect(options).not.toHaveProperty('listen'); + expect(options).not.toHaveProperty('middleware'); }); it('should not start a tunnel or print anything when Makeswift is not active', async () => { @@ -300,16 +338,37 @@ describe('StencilStart unit tests', () => { ); }); + it('should keep the registered session alive with a heartbeat', async () => { + const { + previewSessionHeartbeatFactory, + previewSessionClient, + logger, + instance, + } = await runStart(); + expect(previewSessionHeartbeatFactory).toHaveBeenCalledWith({ + client: previewSessionClient, + session: instance.previewSession, + accessToken: 'token-123', + logger, + }); + expect(previewSessionHeartbeatFactory.mock.results[0].value.start).toHaveBeenCalled(); + }); + it('should stop the tunnel and continue without preview when registration fails', async () => { const previewSessionClient = getPreviewSessionClientStub(); previewSessionClient.register.mockRejectedValue( new Error('Could not register the Makeswift preview session (HTTP 401).'), ); - const { tunnelManager, browserSync, instance, output } = await runStart({ - previewSessionClient, - }); + const { + tunnelManager, + browserSync, + instance, + output, + previewSessionHeartbeatFactory, + } = await runStart({ previewSessionClient }); const { stop } = await tunnelManager.start.mock.results[0].value; expect(stop).toHaveBeenCalled(); + expect(previewSessionHeartbeatFactory).not.toHaveBeenCalled(); expect(instance.previewSession).toBeNull(); expect(instance.startLocalServer.mock.calls[0][2]).toBeUndefined(); expect(output).toContain('Makeswift preview is not available. Could not register'); @@ -324,11 +383,12 @@ describe('StencilStart unit tests', () => { expect(tunnelManager.start).not.toHaveBeenCalled(); }); - it('should stop the tunnel and exit on SIGINT, and exit at once on a second signal', async () => { - const { tunnelManager, processObj } = await runStart(); + it('should stop the heartbeat and tunnel and exit on SIGINT, and exit at once on a second signal', async () => { + const { tunnelManager, processObj, previewSessionHeartbeatFactory } = await runStart(); const { stop } = await tunnelManager.start.mock.results[0].value; await signalHandler(processObj, 'SIGINT')('SIGINT'); + expect(previewSessionHeartbeatFactory.mock.results[0].value.stop).toHaveBeenCalled(); expect(stop).toHaveBeenCalledTimes(1); expect(processObj.exit).toHaveBeenCalledWith(130); diff --git a/package-lock.json b/package-lock.json index 21246e7f..6c35c2ec 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 3111fc8f..6431b7a0 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",