Skip to content
Open
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 test-server/configurator-extension/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copied in from packages/analytics-browser/lib/scripts after a build; see README.md.
vendor/
90 changes: 90 additions & 0 deletions test-server/configurator-extension/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Configurator runner extension

Runs a configuration built in the configurator on a site that doesn't have Amplitude installed, by
injecting the Browser SDK into it. It exists because a web page can't instrument another origin: the
configurator can generate the code, but only an extension can put it on someone else's site.

## Setting it up

The SDK bundles aren't checked in. Build them once, then vendor them in:

```bash
pnpm --dir packages/analytics-browser build
pnpm --dir packages/plugin-session-replay-browser build
node test-server/configurator-extension/sync-vendor.mjs
```

Load `test-server/configurator-extension` at `chrome://extensions` with developer mode on, and start the
test server with `pnpm dev`. After editing any file here, hit the extension's reload icon — that also
clears which tabs were being instrumented.

## Using it

**From the configurator.** Open `/configurator.html`, build a configuration, put a URL in the field next
to the "Run on URL" button, and click it. The page hands the configuration to this extension, which opens
that URL in a new tab and initialises the SDK there. The note beside the button says what was installed,
and the tab's console logs every event the SDK builds.

**On the tab you're looking at.** Click the toolbar button to instrument the current tab with whatever the
configurator sent last, or a debug-everything default if it hasn't sent anything yet. The badge reads `on`,
and clicking again switches it off. Either way the tab reloads, since that's the only way to catch a page
from the start.

## How the pieces fit

`configurator-bridge.js` sits on the configurator page and relays `window.postMessage` requests to
`background.js`. Its `matches` in the manifest cover the hosts the test server uses — `localhost` and
`127.0.0.1` over http for `pnpm dev`, `local.website.com` over https for `pnpm dev:ssh` — and nothing
else. Ports aren't part of a match pattern, so any port is covered, but serving the configurator from a
host that isn't listed is why the page would report no extension. It's scoped to the configurator's own
path rather than a host wildcard so the relay isn't offered to every site, given how much the extension is
allowed to do. Going through the page rather than `chrome.runtime.sendMessage` means the page needs no
extension ID and no `externally_connectable` entry, and it lets the extension announce itself so the
button can tell "not installed" from "not responding".

`background.js` marks a tab, then injects on `webNavigation.onCommitted`: the configuration, the SDK
bundle, session replay's bundle if it's configured, and finally `inject.js`, which wires them together.
All of it goes into the page's main world, because the SDK has to share globals with the page — in an
isolated world its patches to `fetch`, `XMLHttpRequest` and `history` would apply to nothing, and network
and SPA page-view tracking would quietly capture nothing while clicks kept working.

`csp.js` owns the site's Content-Security-Policy: `instrument()` removes it for that one tab and `forget()`
puts it back, so no path can mark a tab without clearing the way or leave a tab unprotected afterwards. It
also records what the policy said, which is the part worth having — see below.

The configuration is materialised on the configurator side by `runtime-config.js`, the same code the run
page uses, and travels as JSON. Regexes have nowhere to live in JSON, so `toJsonSafe()` turns them into
`{ __regex, __flags }` markers and the reviver in `inject.js` turns them back.

## Things worth knowing

- **Host permissions are `*://*/*`.** That's what "any URL a customer gives us" means. A version anyone
else installs should ask per-site with `optional_host_permissions` and a prompt instead.
- **The site's CSP is removed for the tab, and reported.** Injecting bundles works on a page that forbids
inline scripts, because injected files are exempt — but once the SDK runs, its requests are the page's
requests, so a strict `connect-src` or `script-src` blocks event uploads, remote config and the CDN
bundles. A `declarativeNetRequest` session rule removes the `content-security-policy` header, scoped to
the instrumented tab by the `tabIds` condition, which only session-scoped rules support. Note that a
second policy could not have loosened the first: CSP enforces every policy it is given, so appending only
narrows, and a declarative rule can't read the existing value to rewrite it. Removal is the only option
the browser offers.
- **What the policy would have blocked is the finding, not the obstacle.** A customer whose CSP stops
Amplitude wants to know exactly that, so the original policy is compared against what the configuration
will actually reach for and reported in the instrumented page's console and the toolbar button's tooltip.
The comparison is a heuristic, not a CSP implementation: it follows the `default-src` fallback chain and
matches host, wildcard and scheme sources, but ignores ports, paths and scheme upgrades.
- **Only header-delivered policies can be removed.** A policy in a `<meta http-equiv>` tag never crosses
the network and is in force before anything here could see it, which makes `target-page.html` a harsher
case than most real sites. Removal is also per-tab: another tab on the same URL keeps its policy.
- **Guides and Surveys isn't injected.** It fetches its bundle from the CDN per project, and the runner
doesn't load it yet. The site's CSP is no longer the reason — with the header removed, this is only a
matter of `inject.js` learning to add the script tag the run page already uses.
- **Chromium is stricter about UTF-8 than UTF-8 is.** Content script files go through
`base::IsStringUTF8`, which rejects Unicode non-characters, and the session replay bundle contains four
literal U+FFFE characters. Chrome rejects the whole file with "It isn't UTF-8 encoded", which is why
`sync-vendor.mjs` escapes them on the way in rather than a plain `cp`.
- **Where events go.** The API key comes from the configurator, and events land in whatever project owns
it. Use a scratch project, not a customer's production key.
- **The page's own Amplitude.** The bundle merges itself onto an existing `window.amplitude`, which would
leave the site's `init` and `track` pointing at the injected instance. `background.js` snapshots the
global first and `inject.js` puts it back, keeping its own client to itself.
173 changes: 173 additions & 0 deletions test-server/configurator-extension/background.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Decides which tabs get instrumented, and injects the SDK into them.
//
// Injection happens on webNavigation.onCommitted rather than through a registered content script,
// because a registered script can't be handed anything: this has to carry a configuration, and
// executeScript(func, args) is the only injection that takes arguments. The cost is timing — onCommitted
// with injectImmediately lands before the page's own scripts, but a little later than a document_start
// registration would. If catching the very first request ever matters more than being configurable,
// register instead and bake the configuration into a file.
//
// Two things stay true either way, and both matter:
// world: 'MAIN' the SDK shares globals with the page, so it sees the page's own fetch, XHR and
// history. In an isolated world those patches would apply to nothing.
// top frame only for now, so a page full of iframes doesn't get an instance per frame.
import { cspReport, relaxCsp, restoreCsp } from './csp.js';

const SDK_BUNDLE = 'vendor/amplitude-min.js';
const SESSION_REPLAY_BUNDLE = 'vendor/plugin-session-replay-browser-min.js';

const TABS_KEY = 'instrumentedTabs';
const LAST_PAYLOAD_KEY = 'lastPayload';

// What the configurator sends when no API key has been typed in — PLACEHOLDER_API_KEY in its snippet.js.
const PLACEHOLDER_API_KEY = 'YOUR_API_KEY';

// What the toolbar button uses before the configurator has sent anything.
const FALLBACK_PAYLOAD = {
apiKey: 'REPLACE_WITH_A_SCRATCH_PROJECT_KEY',
analytics: {
logLevel: 4,
autocapture: {
attribution: true,
fileDownloads: true,
formInteractions: true,
pageViews: true,
sessions: true,
elementInteractions: true,
frustrationInteractions: true,
// The default only captures 500-599, which makes a healthy site look like nothing is working.
networkTracking: { captureRules: [{ hosts: ['*'], statusCodeRange: '200-599' }] },
webVitals: true,
},
},
sessionReplay: null,
engagement: null,
};

async function instrumentedTabs() {
const { [TABS_KEY]: tabs = {} } = await chrome.storage.session.get(TABS_KEY);
return tabs;
}

// Both transitions carry the CSP rule with them, so no path can mark a tab and forget to clear the way for
// what the SDK is about to do — or leave a tab unprotected after instrumentation stops.
async function instrument(tabId, payload) {
const tabs = await instrumentedTabs();
await chrome.storage.session.set({ [TABS_KEY]: { ...tabs, [tabId]: payload } });
await relaxCsp(tabId);
await chrome.action.setBadgeText({ tabId, text: 'on' });
}

async function forget(tabId) {
const tabs = await instrumentedTabs();
delete tabs[tabId];
await chrome.storage.session.set({ [TABS_KEY]: tabs });
await restoreCsp(tabId);
}

// Runs in the page before the SDK bundle: saves what the page had under window.amplitude, since the
// bundle is about to write over it, and leaves the configuration where inject.js will look for it.
function handOver(payload, csp) {
window.__amplitudeConfigurator = {
hadGlobal: 'amplitude' in window,
properties: window.amplitude ? { ...window.amplitude } : undefined,
payload,
csp,
};
}

chrome.webNavigation.onCommitted.addListener(async ({ tabId, frameId, url }) => {
if (frameId !== 0 || !url.startsWith('http')) {
return;
}
const payload = (await instrumentedTabs())[tabId];
if (!payload) {
return;
}
// Read before injecting: by the time a navigation commits the response headers have arrived, which is
// where the policy the page was sent is still visible.
const csp = cspReport(tabId, payload);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bad server URL aborts injection

Low Severity

cspReport runs outside the injection try/catch and calls new URL(analytics.serverUrl). An invalid custom server URL from the configurator throws before any script is injected, leaves the badge as on, and skips the error path that would mark err.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4126acd. Configure here.

if (csp) {
await chrome.action.setTitle({ tabId, title: csp.summary });
}
const target = { tabId };
const inject = (options) =>
chrome.scripting.executeScript({ target, world: 'MAIN', injectImmediately: true, ...options });
try {
await inject({ func: handOver, args: [payload, csp] });
await inject({ files: [SDK_BUNDLE] });
if (payload.sessionReplay) {
// Its own call: a plugin bundle that won't load shouldn't stop analytics from running, and
// inject.js reports the gap when the global it expects isn't there.
try {
await inject({ files: [SESSION_REPLAY_BUNDLE] });
} catch (error) {
console.warn('[amplitude-configurator] session replay bundle failed to load', error);
}
}
await inject({ files: ['inject.js'] });
} catch (error) {
console.error('[amplitude-configurator] injection failed', error);
await chrome.action.setBadgeText({ tabId, text: 'err' });
}
});

function describe(payload) {
const parts = ['analytics'];
if (payload.sessionReplay) {
parts.push('session replay');
}
let message = `Opened ${payload.url} with ${parts.join(' and ')}.`;
// Said before the navigation happens, so it can only promise the behaviour; what the policy actually
// said reaches the page console, which is where the rest of the run is read anyway.
message += " That tab's Content-Security-Policy is removed, and its console reports what it would have blocked.";
if (payload.engagement) {
// Its bundle is fetched from the CDN at runtime rather than packaged here. A strict CSP no longer
// stands in the way, so this is now only a matter of the runner learning to load it.
message += ' Guides and Surveys was left out: the runner does not load its CDN bundle yet.';
}
if (payload.apiKey === PLACEHOLDER_API_KEY) {
message += ' No API key is set, so events are built but rejected.';
}
return message;
}

async function runOnUrl(payload) {
const url = new URL(payload.url);
if (!/^https?:$/.test(url.protocol)) {
throw new Error('Only http and https URLs can be instrumented.');
}
// The tab opens blank so it can be marked for instrumentation before it commits anything; navigating
// afterwards is what makes the ordering reliable.
const tab = await chrome.tabs.create({ url: 'about:blank', active: true });
await instrument(tab.id, payload);
await chrome.storage.session.set({ [LAST_PAYLOAD_KEY]: payload });
await chrome.tabs.update(tab.id, { url: url.toString() });
return { message: describe(payload) };
}

chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.action !== 'run-on-url') {
return false;
}
runOnUrl(message.payload).then(sendResponse, (error) => sendResponse({ error: error.message }));
return true;
});

// The toolbar button instruments the tab you're looking at, with whatever the configurator sent last.
chrome.action.onClicked.addListener(async (tab) => {
if (!tab.url?.startsWith('http')) {
return;
}
if ((await instrumentedTabs())[tab.id]) {
await forget(tab.id);
await chrome.action.setBadgeText({ tabId: tab.id, text: '' });
} else {
const { [LAST_PAYLOAD_KEY]: lastPayload } = await chrome.storage.session.get(LAST_PAYLOAD_KEY);
await instrument(tab.id, lastPayload ?? FALLBACK_PAYLOAD);
}
// The injection only happens on the next commit, which is also the only way to catch a page early.
await chrome.tabs.reload(tab.id);
});

chrome.tabs.onRemoved.addListener((tabId) => forget(tabId));
24 changes: 24 additions & 0 deletions test-server/configurator-extension/configurator-bridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Sits on the configurator page and relays its requests to the service worker.
//
// This runs in an isolated world, so window.postMessage is the only channel it shares with the page —
// which is the point: the page gets to reach the extension without knowing its ID, and the marker below
// lets the page tell that the extension is there at all.
const REQUEST_SOURCE = 'amplitude-configurator';
const RESPONSE_SOURCE = 'amplitude-configurator-extension';

document.documentElement.dataset.amplitudeConfigurator = chrome.runtime.getManifest().version;

window.addEventListener('message', (event) => {
// Only messages this page sent to itself: anything cross-window is somebody else's business.
if (event.source !== window || event.data?.source !== REQUEST_SOURCE) {
return;
}
const { id, action, payload } = event.data;
chrome.runtime.sendMessage({ action, payload }, (response) => {
const error = chrome.runtime.lastError?.message ?? response?.error;
window.postMessage(
{ source: RESPONSE_SOURCE, id, error, result: error ? undefined : response },
window.location.origin,
);
});
});
Loading
Loading