diff --git a/package.json b/package.json index bedbdea..1165a54 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "reporter-options": "configFile=.mocha-multi.json" }, "devDependencies": { - "@adobe/eslint-config-helix": "3.0.16", + "@adobe/eslint-config-helix": "2.0.9", "@adobe/fetch": "4.2.3", "@cloudflare/workers-types": "4.20250510.0", "@semantic-release/changelog": "6.0.3", diff --git a/src/index.js b/src/index.js index 454a769..339748c 100644 --- a/src/index.js +++ b/src/index.js @@ -430,6 +430,18 @@ async function handleImageRoute(req, env, ctx) { }); } +/** + * Only paths under this prefix are served by the worker. www.aem.live's CDN + * routes exactly this (case-sensitive) prefix to the worker; everything else + * on www.aem.live is served by the aem.live origin directly. + */ +const RUM_PREFIX = '/tools/rum/'; + +/** + * Canonical public origin of the site the worker fronts. + */ +const CANONICAL_ORIGIN = 'https://www.aem.live'; + /** * Handle request * @param {Request} request @@ -440,6 +452,19 @@ async function handleImageRoute(req, env, ctx) { const handleRequest = async (request, env, ctx) => { const url = new URL(request.url); + // Do not serve a copy of the aem.live site on the worker's own hostnames + // (e.g. *.workers.dev), which gets flagged as phishing. Anything outside + // /tools/rum/ is redirected to the canonical site instead of proxied. + if (!url.pathname.startsWith(RUM_PREFIX)) { + return new Response('', { + status: 301, + headers: { + location: `${CANONICAL_ORIGIN}${url.pathname}${url.search}`, + 'cache-control': 'public, max-age=3600', + }, + }); + } + if (url.pathname.startsWith('/tools/rum/_ogimage')) { return handleImageRoute(request, env, ctx); } diff --git a/test/index.test.js b/test/index.test.js index 08a6315..ee1e246 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -11,9 +11,202 @@ */ import assert from 'assert'; +import worker from '../src/index.js'; + +const WORKER_HOST = 'https://rum-proxy-ci.adobeaem.workers.dev'; +const ORIGIN_HOST = 'main--helix-website--adobe.aem.live'; + +const EXPLORER_HTML = 'RUM Explorer'; describe('index tests', () => { - it('should pass', () => { - assert.ok(true); + /** @type {typeof globalThis.fetch} */ + let originalFetch; + /** @type {Array} */ + let fetched; + /** @type {(url: URL) => Response} */ + let respond; + + const ctx = { waitUntil: () => {} }; + + beforeEach(() => { + originalFetch = globalThis.fetch; + fetched = []; + respond = () => new Response('not found', { status: 404 }); + globalThis.fetch = async (input) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + fetched.push(url.href); + return respond(url); + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('paths outside /tools/rum/', () => { + ['/', '/docs/', '/developer/tutorial', '/tools/rum', '/tools/rumx', '/tools/', '/TOOLS/rum/explorer.html'].forEach((path) => { + it(`redirects ${path} to www.aem.live without calling the origin`, async () => { + const resp = await worker.fetch(new Request(`${WORKER_HOST}${path}`), {}, ctx); + assert.strictEqual(resp.status, 301); + assert.strictEqual(resp.headers.get('location'), `https://www.aem.live${path}`); + assert.strictEqual(await resp.text(), ''); + assert.deepStrictEqual(fetched, []); + }); + }); + + it('keeps path and query in the redirect', async () => { + const resp = await worker.fetch(new Request(`${WORKER_HOST}/docs/setup?a=1&b=two%20words`), {}, ctx); + assert.strictEqual(resp.status, 301); + assert.strictEqual(resp.headers.get('location'), 'https://www.aem.live/docs/setup?a=1&b=two%20words'); + assert.deepStrictEqual(fetched, []); + }); + + it('does not serve any branded site content', async () => { + respond = () => new Response('Adobe Experience Manager', { + status: 200, + headers: { 'content-type': 'text/html' }, + }); + const resp = await worker.fetch(new Request(`${WORKER_HOST}/`), {}, ctx); + assert.strictEqual(resp.status, 301); + assert.ok(!(await resp.text()).includes('Adobe')); + assert.deepStrictEqual(fetched, []); + }); + }); + + describe('/tools/rum/explorer.html', () => { + it('still proxies to the origin and adds og meta tags', async () => { + respond = () => new Response(EXPLORER_HTML, { + status: 200, + headers: { 'content-type': 'text/html' }, + }); + const resp = await worker.fetch(new Request(`${WORKER_HOST}/tools/rum/explorer.html?domain=www.example.com&view=week`), {}, ctx); + assert.strictEqual(resp.status, 200); + assert.strictEqual(fetched.length, 1); + const upstream = new URL(fetched[0]); + assert.strictEqual(upstream.hostname, ORIGIN_HOST); + assert.strictEqual(upstream.pathname, '/tools/rum/explorer.html'); + const html = await resp.text(); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('https://www.aem.live/tools/rum/_ogimage?domain=www.example.com&view=week') + || html.includes('https://www.aem.live/tools/rum/_ogimage?domain=www.example.com&view=week')); + }); + + it('escapes parameters in og meta tags', async () => { + respond = () => new Response(EXPLORER_HTML, { status: 200 }); + const resp = await worker.fetch(new Request(`${WORKER_HOST}/tools/rum/explorer.html?domain=${encodeURIComponent('">')}`), {}, ctx); + const html = await resp.text(); + assert.ok(!html.includes('')); + assert.ok(html.includes('"><script>x</script>')); + }); + + it('passes through non-ok origin responses', async () => { + respond = () => new Response('gone', { status: 404 }); + const resp = await worker.fetch(new Request(`${WORKER_HOST}/tools/rum/explorer.html`), {}, ctx); + assert.strictEqual(resp.status, 404); + assert.strictEqual(fetched.length, 1); + }); + }); + + describe('other /tools/rum/ assets', () => { + it('are proxied from the origin unchanged', async () => { + respond = () => new Response('export default 1;', { + status: 200, + headers: { 'content-type': 'application/javascript' }, + }); + const resp = await worker.fetch(new Request(`${WORKER_HOST}/tools/rum/elements/list-facet.js?v=1`), {}, ctx); + assert.strictEqual(resp.status, 200); + assert.strictEqual(await resp.text(), 'export default 1;'); + assert.strictEqual(fetched.length, 1); + const upstream = new URL(fetched[0]); + assert.strictEqual(upstream.hostname, ORIGIN_HOST); + assert.strictEqual(upstream.pathname, '/tools/rum/elements/list-facet.js'); + assert.strictEqual(upstream.search, '?v=1'); + }); + }); + + describe('/tools/rum/_cors', () => { + it('returns 400 for a missing url', async () => { + const resp = await worker.fetch(new Request(`${WORKER_HOST}/tools/rum/_cors`), {}, ctx); + assert.strictEqual(resp.status, 400); + assert.strictEqual(resp.headers.get('x-error'), 'invalid url'); + assert.deepStrictEqual(fetched, []); + }); + + it('returns 403 for an invalid domainkey', async () => { + const target = 'https://www.example.com/data.json'; + const resp = await worker.fetch(new Request(`${WORKER_HOST}/tools/rum/_cors?url=${encodeURIComponent(target)}&domainkey=bad`), {}, ctx); + assert.strictEqual(resp.status, 403); + assert.strictEqual(resp.headers.get('x-error'), 'invalid domainkey'); + assert.strictEqual(fetched.length, 1); + assert.ok(fetched[0].startsWith('https://bundles.aem.page/domains/www.example.com')); + }); + + it('proxies the target with CORS headers for a valid domainkey', async () => { + const target = 'https://www.example.com/data.json'; + respond = (url) => { + if (url.hostname === 'bundles.aem.page') { + return new Response('{}', { status: 200 }); + } + return new Response('{"ok":true}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + const resp = await worker.fetch(new Request(`${WORKER_HOST}/tools/rum/_cors?url=${encodeURIComponent(target)}&domainkey=good`), {}, ctx); + assert.strictEqual(resp.status, 200); + assert.strictEqual(resp.headers.get('access-control-allow-origin'), '*'); + assert.strictEqual(await resp.text(), '{"ok":true}'); + assert.deepStrictEqual(fetched.map((u) => new URL(u).hostname), ['bundles.aem.page', 'www.example.com']); + }); + }); + + describe('/tools/rum/_ogimage', () => { + it('returns 400 for missing domain or view', async () => { + const resp = await worker.fetch(new Request(`${WORKER_HOST}/tools/rum/_ogimage`), {}, ctx); + assert.strictEqual(resp.status, 400); + assert.strictEqual(resp.headers.get('x-error'), 'missing domain or view'); + assert.deepStrictEqual(fetched, []); + }); + + it('serves a stored image from the bucket', async () => { + const keys = []; + const env = { + IMAGE_BUCKET: { + head: async (key) => { + keys.push(key); + return { customMetadata: { state: 'loaded' } }; + }, + get: async () => ({ + arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer, + httpMetadata: { contentType: 'image/jpeg' }, + }), + }, + }; + const resp = await worker.fetch(new Request(`${WORKER_HOST}/tools/rum/_ogimage?view=week&domain=www.example.com`), env, ctx); + assert.strictEqual(resp.status, 200); + assert.strictEqual(resp.headers.get('content-type'), 'image/jpeg'); + assert.deepStrictEqual(keys, ['images/www.example.com/week/domain=www.example.com&view=week']); + assert.deepStrictEqual(fetched, []); + }); + + it('redirects to the default image while a screenshot is pending', async () => { + const waited = []; + const env = { + IMAGE_BUCKET: { + head: async () => ({ customMetadata: { state: 'pending' } }), + }, + }; + const resp = await worker.fetch( + new Request(`${WORKER_HOST}/tools/rum/_ogimage?view=week&domain=www.example.com`), + env, + { waitUntil: (p) => waited.push(p) }, + ); + assert.strictEqual(resp.status, 302); + assert.ok(resp.headers.get('location').startsWith('https://www.aem.live/default-social.png')); + assert.strictEqual(waited.length, 1); + await waited[0]; + }); }); }); diff --git a/test/post-deploy.test.js b/test/post-deploy.test.js index 63feecc..dd6d034 100644 --- a/test/post-deploy.test.js +++ b/test/post-deploy.test.js @@ -264,6 +264,40 @@ describe('Post-Deploy Tests', () => { }); }); + // On the worker's own hostname (*.workers.dev) the aem.live site must not be + // served; only /tools/rum/ is handled by the worker. On www.aem.live, these + // paths are served by the aem.live origin directly, not by this worker. + (ENVIRONMENT === 'production' ? describe.skip : describe)('No site proxy outside /tools/rum/', () => { + it('redirects /docs/ to www.aem.live instead of serving the site', async () => { + const response = await fetch(`https://${TEST_DOMAIN}/docs/?x=1`, { redirect: 'manual' }); + assert.strictEqual(response.status, 301, 'Should return 301'); + assert.strictEqual(response.headers.get('location'), 'https://www.aem.live/docs/?x=1'); + const body = await response.text(); + assert.ok(!body.includes(' { + const response = await fetch(`https://${TEST_DOMAIN}/`, { redirect: 'manual' }); + // 301 from the worker, or 403 if Cloudflare's phishing interstitial + // still answers the root URL before the worker runs. + assert.ok( + [301, 403].includes(response.status), + `Should return 301 or 403, got ${response.status}`, + ); + if (response.status === 301) { + assert.strictEqual(response.headers.get('location'), 'https://www.aem.live/'); + } + const body = await response.text(); + assert.ok(!body.includes('Adobe Experience Manager'), 'Should not serve the aem.live page'); + }); + + it('still serves /tools/rum/_cors from the worker', async () => { + const response = await fetch(`https://${TEST_DOMAIN}/tools/rum/_cors`, { redirect: 'manual' }); + assert.strictEqual(response.status, 400); + assert.strictEqual(response.headers.get('x-error'), 'invalid url'); + }); + }); + // Keep the original simple test for backwards compatibility it('passes', async () => { assert.ok(true);