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);