Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/MakeswiftPreview.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import tunnelManagerModule from './TunnelManager.js';
import previewSessionClientModule from './PreviewSessionClient.js';
import PreviewSessionHeartbeat from './PreviewSessionHeartbeat.js';
import { createPreviewProxy } from './preview-proxy.js';
import { createStorefrontLinkRewriteRule } from './storefront-link-rewrite.js';

const PREVIEW_LISTEN_HOST = 'localhost';
// Ctrl-C must not be slow. A session that is not revoked expires with its TTL.
Expand Down Expand Up @@ -147,6 +148,7 @@ class MakeswiftPreview {
logger: this._logger,
}),
],
rewriteRules: [createStorefrontLinkRewriteRule(this._session.storeUrl)],
tunnel: false,
};
}
Expand Down
11 changes: 10 additions & 1 deletion lib/MakeswiftPreview.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,21 @@ describe('MakeswiftPreview', () => {
describe('getBrowserSyncOptions', () => {
it('should serve a signing proxy to the storefront on loopback only', async () => {
const { preview, previewProxyFactory, logger } = await startPreview();
expect(preview.getBrowserSyncOptions(localPort)).toEqual({
const { rewriteRules, ...options } = preview.getBrowserSyncOptions(localPort);
expect(options).toEqual({
listen: 'localhost',
server: { baseDir: [] },
middleware: [previewProxyMiddleware],
tunnel: false,
});
// The rule itself is tested in storefront-link-rewrite.spec.js.
expect(rewriteRules).toHaveLength(1);
const [{ match, fn }] = rewriteRules;
const html = '<a href="https://www.example.com/products">';
const rewritten = html.replace(match, (...args) =>
fn({ headers: { host: 'localhost:3000' } }, {}, ...args),
);
expect(rewritten).toBe('<a href="http://localhost:3000/products">');
expect(previewProxyFactory).toHaveBeenCalledWith({
previewSession: preview.session,
localPort,
Expand Down
46 changes: 46 additions & 0 deletions lib/storefront-link-rewrite.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { escapeRegExp } from 'lodash-es';

// The link must start here. This stops a match on the "//host" part of "ftp://host" or
// "https://other.test//host".
const LINK_START = String.raw`(?<![\w:])`;

// "https:", "http:", or nothing (a protocol-relative link).
const SCHEME = '(?:https?:)?';

// "//", or "\/\/" in JSON-escaped strings. The capture lets the output keep the same style.
const SLASHES = String.raw`(\\?/\\?/)`;

// The host must end here. This stops a match on a longer host ("host.evil.test", "host-2.com")
// or on a different port ("host:8443"). A dot that ends a sentence ("Visit https://host.") is
// not part of the host, so the link is still rewritten.
const HOST_END = String.raw`(?![\w:-]|\.[^\s"'<])`;

/**
* @param {string} storeHost - host (and port, if not default) of the storefront, e.g. `www.example.com`
* @returns {RegExp} matches absolute, protocol-relative, and JSON-escaped links to the host
*/
function buildStorefrontLinkPattern(storeHost) {
return new RegExp(LINK_START + SCHEME + SLASHES + escapeRegExp(storeHost) + HOST_END, 'gi');
}

/**
* Makes a BrowserSync rewrite rule that changes absolute storefront links in HTML to the local
* front door, so that navigation stays on localhost. Normal proxy mode does the same for the
* renderer host.
*
* BrowserSync (resp-modifier) calls `fn(req, res, ...String.prototype.replace callback args)`.
*
* @param {string} storeUrl - e.g. `https://www.example.com`
* @returns {{ match: RegExp, fn: Function }}
*/
function createStorefrontLinkRewriteRule(storeUrl) {
return {
match: buildStorefrontLinkPattern(new URL(storeUrl).host),
fn: (req, _res, _matched, slashes) => {
const localSlashes = slashes.includes('\\') ? '\\/\\/' : '//';
return `http:${localSlashes}${req.headers.host}`;
},
};
}

export { buildStorefrontLinkPattern, createStorefrontLinkRewriteRule };
163 changes: 163 additions & 0 deletions lib/storefront-link-rewrite.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import {
buildStorefrontLinkPattern,
createStorefrontLinkRewriteRule,
} from './storefront-link-rewrite.js';

const LOCAL_HOST = 'localhost:3000';

// Applies the rule the same way BrowserSync (resp-modifier) does.
const rewrite = (html, storeUrl = 'https://www.example.com') => {
const rule = createStorefrontLinkRewriteRule(storeUrl);
return html.replace(rule.match, (...args) =>
rule.fn({ headers: { host: LOCAL_HOST } }, {}, ...args),
);
};

describe('createStorefrontLinkRewriteRule', () => {
describe('rewrites links to the storefront host', () => {
it.each([
['https link', 'https://www.example.com/products', 'http://localhost:3000/products'],
['http link', 'http://www.example.com/products', 'http://localhost:3000/products'],
[
'protocol-relative link',
'//www.example.com/products',
'http://localhost:3000/products',
],
[
'uppercase link',
'HTTPS://WWW.EXAMPLE.COM/products',
'http://localhost:3000/products',
],
['host with no path', 'https://www.example.com', 'http://localhost:3000'],
['host then query', 'https://www.example.com?a=1', 'http://localhost:3000?a=1'],
['host then hash', 'https://www.example.com#top', 'http://localhost:3000#top'],
['host in double quotes', '"https://www.example.com"', '"http://localhost:3000"'],
['host in single quotes', "'https://www.example.com'", "'http://localhost:3000'"],
['host then <', 'https://www.example.com</a>', 'http://localhost:3000</a>'],
[
'host at end of sentence',
'Visit https://www.example.com.',
'Visit http://localhost:3000.',
],
[
'JSON-escaped link',
String.raw`"https:\/\/www.example.com\/products"`,
String.raw`"http:\/\/localhost:3000\/products"`,
],
[
'JSON-escaped protocol-relative link',
String.raw`"\/\/www.example.com\/products"`,
String.raw`"http:\/\/localhost:3000\/products"`,
],
[
'link in a query param of another link',
'https://other.test/?r=https://www.example.com/x',
'https://other.test/?r=http://localhost:3000/x',
],
])('%s', (_name, input, expected) => {
expect(rewrite(input)).toBe(expected);
});

it('rewrites all links in a document', () => {
const html = [
'<a href="https://www.example.com/a">A</a>',
'<img src="//www.example.com/b.png">',
String.raw`<script>{"url":"https:\/\/www.example.com\/c"}</script>`,
].join('\n');

expect(rewrite(html)).toBe(
[
'<a href="http://localhost:3000/a">A</a>',
'<img src="http://localhost:3000/b.png">',
String.raw`<script>{"url":"http:\/\/localhost:3000\/c"}</script>`,
].join('\n'),
);
});

it('gives the same result when the rule is used more than once', () => {
// A global RegExp keeps lastIndex; String.prototype.replace must reset it.
const rule = createStorefrontLinkRewriteRule('https://www.example.com');
const apply = (html) =>
html.replace(rule.match, (...args) =>
rule.fn({ headers: { host: LOCAL_HOST } }, {}, ...args),
);

expect(apply('https://www.example.com/a')).toBe('http://localhost:3000/a');
expect(apply('https://www.example.com/a')).toBe('http://localhost:3000/a');
});

it('uses the Host header of the request', () => {
const rule = createStorefrontLinkRewriteRule('https://www.example.com');
const result = 'https://www.example.com/a'.replace(rule.match, (...args) =>
rule.fn({ headers: { host: '127.0.0.1:4000' } }, {}, ...args),
);

expect(result).toBe('http://127.0.0.1:4000/a');
});
});

describe('does not change links to other hosts', () => {
it.each([
['longer host (subdomain suffix)', 'https://www.example.com.evil.test/'],
['longer host (hyphen)', 'https://www.example.com-evil.test/'],
['longer host (more letters)', 'https://www.example.community/'],
['subdomain of the host', 'https://shop.www.example.com/'],
['parent domain', 'https://example.com/'],
['same host, other port', 'https://www.example.com:8443/'],
['host with trailing dot (FQDN)', 'https://www.example.com./x'],
['host with user info', 'https://user@www.example.com/'],
['host with no slashes', 'www.example.com/products'],
['host as a path segment', '/www.example.com/products'],
['other scheme', 'ftp://www.example.com/'],
['host after // in a path', 'https://other.test//www.example.com/'],
['dot in host is not a wildcard', 'https://wwwXexampleYcom/'],
])('%s', (_name, input) => {
expect(rewrite(input)).toBe(input);
});
});

describe('known limits (not rewritten)', () => {
it.each([
['URL-encoded link', 'https%3A%2F%2Fwww.example.com%2Fx'],
['double JSON-escaped link', String.raw`https:\\\/\\\/www.example.com`],
])('%s', (_name, input) => {
expect(rewrite(input)).toBe(input);
});

it('does not rewrite the bare domain when the store URL has www', () => {
expect(rewrite('https://example.com/x')).toBe('https://example.com/x');
});
});

describe('store URL with a port', () => {
const storeUrl = 'https://store.test:8443';

it('rewrites links with the same port', () => {
expect(rewrite('https://store.test:8443/x', storeUrl)).toBe('http://localhost:3000/x');
});

it.each([
['no port', 'https://store.test/x'],
['other port', 'https://store.test:9443/x'],
['longer port', 'https://store.test:84430/x'],
])('does not rewrite links with %s', (_name, input) => {
expect(rewrite(input, storeUrl)).toBe(input);
});
});

it('ignores a default port in the store URL', () => {
// `new URL('https://host:443').host` is `host`.
expect(rewrite('https://www.example.com/x', 'https://www.example.com:443')).toBe(
'http://localhost:3000/x',
);
});
});

describe('buildStorefrontLinkPattern', () => {
it('escapes regex characters in the host', () => {
const pattern = buildStorefrontLinkPattern('a.b');

expect('https://a.b/'.match(pattern)).toEqual(['https://a.b']);
expect('https://aXb/'.match(pattern)).toBeNull();
});
});
Loading