diff --git a/apps/docs/src/content/contributing/chrome-extension.md b/apps/docs/src/content/contributing/chrome-extension.md index d1bdaced..e6bda6b9 100644 --- a/apps/docs/src/content/contributing/chrome-extension.md +++ b/apps/docs/src/content/contributing/chrome-extension.md @@ -36,6 +36,13 @@ extension/ | `optional_host_permissions` | `http://*/*` and `https://*/*`. The panel requests one host at a time, only when you click **Allow access**. | | `content_scripts` | `content-script.js` and `detect-angular.js`, on every page. | | `minimum_chrome_version` | `111`. | +| `key` | The public key that fixes the extension ID to `dcogniffeelebaolkkfbopmjcblhblfk`. | + +### Extension ID + +Chrome derives the ID of an extension from its public key. The `key` in `manifest.json` gives every build the ID `dcogniffeelebaolkkfbopmjcblhblfk`, whether you load it unpacked or install it from the store. The Vite plugin and the Express hub trust `chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk` by default, through `PANGULAR_EXTENSION_IDS` in `packages/devtools/src/extension-origin.ts`. + +The maintainers keep the matching private key for the Chrome Web Store upload. It is not in the repository, and you never need it to build or load the extension. If you change the `key`, the ID changes too, and the server refuses the extension until its origin is in `allowedOrigins` or its ID is in `PANGULAR_EXTENSION_IDS`. ## Build @@ -74,13 +81,21 @@ After a rebuild, click the reload icon on the extension card, then reopen DevToo pnpm extension:zip ``` -This runs `extension:build`, then writes `dist/pangular-inspector-extension.zip`. The zip leaves out `.DS_Store` files. +This runs `extension:build`, then writes `dist/pangular-inspector-extension.zip`. The zip leaves out `.DS_Store` files and drops `key` from the manifest, because the Chrome Web Store refuses a manifest with a `key`. + +For the first upload of a new store item, give the script the private key so the store keeps the ID `dcogniffeelebaolkkfbopmjcblhblfk`: + +```bash +PANGULAR_EXTENSION_KEY=/path/to/pangular-inspector-extension-key.pem pnpm extension:zip +``` + +The key goes into the zip as `key.pem`, and the script stops if it doesn't match the `key` in the manifest. Later updates don't need it. ### Upload 1. Bump `version` in `extension/manifest.json`. 2. Go to the Chrome Developer Dashboard. -3. Click **New item** (or open the existing item) and upload the zip. +3. Click **New item** (or open the existing item) and upload the zip. For **New item**, build the zip with `PANGULAR_EXTENSION_KEY` set, as above. 4. Fill in the listing details and submit for review. diff --git a/apps/docs/src/content/getting-started/chrome-extension.md b/apps/docs/src/content/getting-started/chrome-extension.md index c442ec84..a9f5db0b 100644 --- a/apps/docs/src/content/getting-started/chrome-extension.md +++ b/apps/docs/src/content/getting-started/chrome-extension.md @@ -129,7 +129,27 @@ Other hosts are optional host permissions. **Allow access** asks Chrome for the Granting the extension a host doesn't change what the devtools server accepts. The server still applies its own checks. The Vite plugin, for example, only answers requests from a loopback address. See [Access and redaction](../security.md). -The Vite plugin and the Express hub accept the extension's `chrome-extension://` origin by default. If your Express hub passes its own `allowedOrigins` list, add `chrome-extension://` to it. The ID is on the extension card in `chrome://extensions`. +### Server origin + +The panel sends requests from its own origin, `chrome-extension://`. Any installed extension can send requests to a loopback host, so the Vite plugin and the Express hub trust only the extension IDs they know. + +The `key` in `extension/manifest.json` fixes the ID of this extension to `dcogniffeelebaolkkfbopmjcblhblfk`, wherever you load it from. The Vite plugin and the Express hub trust `chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk` by default, so the panel works with no `allowedOrigins` setting. They refuse every other extension origin. + +If you build the extension with another `key`, or without one, Chrome gives it another ID. Copy that ID from the extension card in `chrome://extensions` and add its origin to `allowedOrigins`: + +```ts +// vite.config.ts +pangular({allowedOrigins: ['chrome-extension://']}); +``` + +```ts +// src/server.ts +const devtools = initPangularHub({ + allowedOrigins: ['chrome-extension://'], +}); +``` + +In the Vite plugin, an extension entry does not turn the one-time code on. If the server refuses a build with another ID, the panel names its own origin in the message. See [Access and redaction](../security.md#chrome-extension). ### Content scripts @@ -148,7 +168,7 @@ The content scripts are wider. Two of them run on every page. They check for an None of them served a connection file. The status next to each URL shows what the server answered. Check that the server of the page mounts the devtools and that the server accepts the request, then click Try again. See Access and redaction. - The server answered 401 or 403. The Vite plugin refuses requests that do not come from your machine. Open the app on localhost, or see Answers only your machine. + The server answered 401 or 403. If you built the extension with another ID, the server refuses its origin until you add it to allowedOrigins. On 403, the message names the origin to add. See Server origin. The Vite plugin also refuses requests that do not come from your machine. Open the app on localhost, or see Answers only your machine. The overlay on the inspected page did not report its page id within five seconds, so the panel loaded without it. Check that the overlay starts on that page, then close and reopen DevTools. diff --git a/apps/docs/src/content/getting-started/configuration.md b/apps/docs/src/content/getting-started/configuration.md index 65e49449..b0367ce8 100644 --- a/apps/docs/src/content/getting-started/configuration.md +++ b/apps/docs/src/content/getting-started/configuration.md @@ -23,6 +23,8 @@ These three functions take the same options: The `pangular` binary reads the same options from a JSON file, for `dev`, `build` and `mcp`. See [Config file](./cli.md#config-file). +The access options stay with the server. The Express hub and the Vite plugin trust the [Chrome extension](./chrome-extension.md#server-origin) by its fixed ID, with no setting. A build of the extension with another ID needs its `chrome-extension://` origin in `allowedOrigins`. + ### Express hub Pass the options next to the [access options](../security.md#express-hub) `auth`, `allowedOrigins` and `mcp`: diff --git a/apps/docs/src/content/getting-started/express.md b/apps/docs/src/content/getting-started/express.md index 2df0a224..135ecc2f 100644 --- a/apps/docs/src/content/getting-started/express.md +++ b/apps/docs/src/content/getting-started/express.md @@ -100,7 +100,7 @@ With `ws: {sidecar: true}`, the WebSocket runs on its own port, picked automatic | `base` | `'/__devframes/'` | Where the hub is mounted. The devtools panel lives at `pangular/`. | | `ws` | | `false` uses server-sent events only. `{ sidecar: true }` runs the WebSocket on its own port. | | `auth` | on | `false` turns off the one-time code. | -| `allowedOrigins` | loopback origins and the Chrome extension | Extra origins allowed to open the WebSocket. A list replaces the Chrome extension default. `false` turns the origin check off. | +| `allowedOrigins` | loopback origins and the Chrome extension | Extra origins allowed to open the WebSocket, such as a tunnel. `false` turns the origin check off. | | `mcp` | a bearer token | Mounts the MCP endpoint at `__mcp` and asks for a bearer token. With `auth: false` the default is `'auto'`: it mounts once agent tools exist and asks for no token. See [Send a token](../agents/mcp-server.md#send-a-token). | The hub also takes the devtools options, such as `inspectors`, `agent`, `actions`, `redaction` and `limits`. See [Configuration](./configuration.md). @@ -109,7 +109,7 @@ The hub also takes the devtools options, such as `inspectors`, `agent`, `actions The hub protects its connection with a one-time code by default. The server prints the code, and a browser can read data only after it exchanges that code. On a machine only you use, pass `auth: false` to turn the gate off. -The origin check is on by default too. Only loopback origins and the [Chrome extension](./chrome-extension.md) can open the WebSocket. If you pass your own `allowedOrigins` list, it keeps loopback origins but drops the extension. Add `chrome-extension://` to the list, with the ID from `chrome://extensions`: +The origin check is on by default too. Only loopback origins and the [Chrome extension](./chrome-extension.md#server-origin), `chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk`, can open the WebSocket. A list in `allowedOrigins` keeps both and adds its entries. A build of the extension with another ID needs its own entry, with the ID from `chrome://extensions`: ```ts // src/server.ts diff --git a/apps/docs/src/content/getting-started/vite.md b/apps/docs/src/content/getting-started/vite.md index 241da16f..5247ac58 100644 --- a/apps/docs/src/content/getting-started/vite.md +++ b/apps/docs/src/content/getting-started/vite.md @@ -120,7 +120,7 @@ The plugin reads `apiPrefix` from your Analog config. Set it here only when the ### `allowedOrigins` -Each entry is an origin, such as `https://tunnel.example`. The request itself must still come from a loopback address. +Each entry is an origin, such as `https://tunnel.example` or `chrome-extension://`. The request itself must still come from a loopback address. The plugin trusts the [Chrome extension](./chrome-extension.md#server-origin) by its fixed ID with no entry, and refuses every other extension that is not listed. Add an entry only for a build of the extension with another ID. An extension entry does not turn the one-time code on. The plugin reads each entry the way a browser sends an origin: it drops a path or a trailing slash and lowercases the host, so `'https://Tunnel.example/app/'` allows `https://tunnel.example`. It prints a warning in the terminal when it changes an entry, and it ignores an entry that is not a URL, such as `'tunnel.example'`. The first request from each origin that the check refuses also prints a warning that names the origin. diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md index 6fcda6c6..6ae381b9 100644 --- a/apps/docs/src/content/security.md +++ b/apps/docs/src/content/security.md @@ -19,10 +19,10 @@ The devtools read your running app and send what they find to a server on your m - Loopback requests only. A request that sends an Origin must come from a loopback host, a Chrome extension, allowedOrigins or Vite's server.allowedHosts. Asks for a one-time code when a non-loopback host or origin is allowed. + Loopback requests only. A request that sends an Origin must come from a loopback host, the Pangular Inspector extension, an entry in allowedOrigins or Vite's server.allowedHosts. Asks for a one-time code when a non-loopback host or origin is allowed. - A one-time code and an origin check that accepts loopback origins and the Chrome extension. Both on by default. + A one-time code and an origin check that accepts loopback origins and the Pangular Inspector extension. Both on by default. Binds to localhost and asks for a one-time code by default. @@ -36,12 +36,12 @@ The devtools read your running app and send what they find to a server on your m ### Vite plugin -The devtools only answer requests from this machine. When a request carries an `Origin` header, that origin must be a loopback host, the Chrome extension or an origin you allowed. Requests without an `Origin` header pass the origin check. Browsers leave the header out of some cross-site requests, such as image loads and link clicks, so the origin check alone does not stop every request from another website. +The devtools only answer requests from this machine. When a request carries an `Origin` header, that origin must be a loopback host, the Pangular Inspector extension or an origin you allowed. Requests without an `Origin` header pass the origin check. Browsers leave the header out of some cross-site requests, such as image loads and link clicks, so the origin check alone does not stop every request from another website. In detail, a request to the devtools must: - come from a loopback address (any `127.x.x.x` address or `::1`), and -- have no `Origin` header, or an origin that is a loopback host, a Chrome extension, an entry in `allowedOrigins`, or a host that Vite's `server.allowedHosts` accepts. +- have no `Origin` header, or an origin that is a loopback host, the [Pangular Inspector extension](#chrome-extension), an entry in `allowedOrigins`, or a host that Vite's `server.allowedHosts` accepts. Other requests get `403` with the message "Pangular Inspector only answers requests from this machine." WebSocket upgrades follow the same rules. @@ -79,10 +79,10 @@ With only loopback hosts allowed, the loopback and origin checks take the place `initPangularHub()` has two checks, both on by default: -| Check | Option | What it does | -| ------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| One-time code | `auth` | The server prints a code. A browser can read data only after it exchanges that code. | -| Origin check | `allowedOrigins` | Only loopback origins, the Chrome extension, or clients that send no `Origin`, can open the WebSocket. Pass a list to allow more origins. | +| Check | Option | What it does | +| ------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| One-time code | `auth` | The server prints a code. A browser can read data only after it exchanges that code. | +| Origin check | `allowedOrigins` | Only loopback origins, the Pangular Inspector extension, or clients that send no `Origin`, can open the WebSocket. Pass a list to allow more origins. | ```ts {8} // src/server.ts @@ -97,7 +97,7 @@ const devtools = initPangularHub({ app.use(devtools.nodeMiddleware); ``` -A list keeps loopback origins but replaces the Chrome extension default. If you use the extension with your own list, add its origin, `chrome-extension://`, with the ID from `chrome://extensions`. +A list keeps loopback origins and the Pangular Inspector extension. See [Chrome extension](#chrome-extension). Pass auth: false only on a machine only you use. Keep it on when you allow a tunnel origin: the origin check does not tell who is on the other end of the tunnel. allowedOrigins: false turns the origin check off. Keep the check on for your own apps. @@ -121,7 +121,11 @@ The extension has host permissions for loopback hosts only: `localhost` and its On any other host, the panel doesn't send a request until you click **Allow access**. Chrome then asks you to grant the extension that one host, on the scheme of the page and any port. The extension never asks for all hosts at once. -Granting the extension a host doesn't change what the devtools server accepts. The server still applies the checks on this page. Both the Vite plugin and the Express hub accept the extension's `chrome-extension://` origin by default. An Express hub with its own `allowedOrigins` list needs the extension origin in that list. See [Chrome extension](./getting-started/chrome-extension.md#host-access). +Granting the extension a host doesn't change what the devtools server accepts. The server still applies the checks on this page. + +Every installed extension can send requests to a loopback host, with its own `chrome-extension://` origin. So the Vite plugin and the Express hub accept one extension by default: `chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk`. The `key` in the extension manifest fixes that ID, so the unpacked extension and the store build share it. Every other extension origin is refused. + +If you build the extension with another `key`, add its origin to `allowedOrigins`. Each entry names one extension. The ID is on the extension card in `chrome://extensions`. In the Vite plugin, an extension entry does not turn the one-time code on. See [Server origin](./getting-started/chrome-extension.md#server-origin). ## What is redacted diff --git a/extension/manifest.json b/extension/manifest.json index 1cba06ea..139730cf 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,6 +2,7 @@ "manifest_version": 3, "name": "Pangular Inspector", "version": "0.0.7", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsfLHWE3YKol6yLgiD8bc7p8zDEB4dN4Wlj+DqE9laAym8LuthA0YfjeDTdvUJ3HoL9ypRRXa2MmDL1GziCcd1zNF1lhCYN6zyZu999Cb0YWR/yQg/JiSBbNOHw7DdM5LVlov5J0SB/2vc87OYo4Q9kSuanS50pz7T22klLTNaeGq7E3jHHTF/dcJ8rYubDwDdMHRzRtW3hnpQfHQjrAgSpg8JhBXrR7RTuU2CjUeUXHMV3wYALWsS63MZRAK4BIEWZn+jE41FTe02f9V5XKoXnTtG11AEPa1AbJvhaLvnAil4PliWx0Lv9Uu/YVjTSkG8l+87K/9MFmPgFfLKOmjKQIDAQAB", "description": "Inspect Angular components, signals, dependency injection, and routes.", "minimum_chrome_version": "111", "devtools_page": "devtools.html", diff --git a/extension/panel-bridge.js b/extension/panel-bridge.js index 68cc4d77..2a9f9638 100644 --- a/extension/panel-bridge.js +++ b/extension/panel-bridge.js @@ -18,6 +18,7 @@ const REFUSED_DOCS = { const PATHS = ['/__pangular/', '/__devframes/pangular/', '/__devframe/', '/']; const CONNECTION_FILES = ['__devframe/__connection.json', '__connection.json']; const PROBE_TIMEOUT_MS = 1500; +const PINNED_ORIGIN = 'chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk'; const REFUSED_TEXT_LIMIT = 200; const PAGE_ID_WAIT_MS = 5000; const PAGE_ID_POLL_MS = 250; @@ -97,8 +98,13 @@ async function detectConnection() { const tried = probes.map(({ url, status }) => `${url} (${status ?? 'no answer'})`); if (refused) { const reason = refused.text ? ` It said: "${refused.text}"` : ''; + const extension = chrome.runtime.getURL('').replace(/\/$/, ''); + const hint = + refused.status === 403 && extension !== PINNED_ORIGIN + ? ` If the page runs on this machine, add ${extension} to allowedOrigins to trust this extension. That does not change the rule that the server only answers this machine.` + : ''; showStatus( - `The devtools server on ${page.origin} refused the request (${refused.status}).${reason} Tried:`, + `The devtools server on ${page.origin} refused the request (${refused.status}).${reason}${hint} Tried:`, { tried, retry: true, docs: REFUSED_DOCS }, ); } else { diff --git a/package.json b/package.json index f8a9eb12..03ec1ae2 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "devtools:publish": "pnpm --filter @pangular-inspector/devtools publish --access public", "verify:publish": "node scripts/verify-publish.mjs", "extension:build": "pnpm devtools:build && rm -rf extension/ui && cp -r dist/devtools-ui extension/ui", - "extension:zip": "pnpm extension:build && rm -f dist/pangular-inspector-extension.zip && cd extension && zip -r ../dist/pangular-inspector-extension.zip . -x '*.DS_Store'", + "extension:zip": "pnpm extension:build && node scripts/extension-zip.mjs", "analog:dev": "pnpm --filter analog-demo dev", "docs:dev": "nx serve pangular-inspector-docs", "docs:build": "nx build pangular-inspector-docs", diff --git a/packages/devtools/src/__tests__/extension-origin.test.ts b/packages/devtools/src/__tests__/extension-origin.test.ts new file mode 100644 index 00000000..b2588feb --- /dev/null +++ b/packages/devtools/src/__tests__/extension-origin.test.ts @@ -0,0 +1,93 @@ +import { createHash, createPublicKey } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + PANGULAR_EXTENSION_IDS, + extensionOrigin, + isAllowedExtensionOrigin, +} from '../extension-origin.ts'; + +const PINNED = 'dcogniffeelebaolkkfbopmjcblhblfk'; +const OURS = 'abcdefghijklmnopabcdefghijklmnop'; +const OTHER = 'ponmlkjihgfedcbaponmlkjihgfedcba'; + +describe('extensionOrigin', () => { + it('reads a Chrome extension origin with a well-formed ID', () => { + expect(extensionOrigin(`chrome-extension://${OURS}`)).toBe(`chrome-extension://${OURS}`); + expect(extensionOrigin(`chrome-extension://${OURS.toUpperCase()}/`)).toBe( + `chrome-extension://${OURS}`, + ); + expect(extensionOrigin(`chrome-extension://${OURS}/ui/index.html`)).toBe( + `chrome-extension://${OURS}`, + ); + }); + + it('rejects other schemes and malformed IDs', () => { + for (const value of [ + 'chrome-extension://', + 'chrome-extension://abcdefghijklmnop', + 'chrome-extension://localhost', + `chrome-extension://${'z'.repeat(32)}`, + `moz-extension://${OURS}`, + `https://${OURS}`, + 'not a url', + ]) { + expect(extensionOrigin(value)).toBeUndefined(); + } + }); +}); + +describe('isAllowedExtensionOrigin', () => { + it('trusts the extension ID pinned by the manifest key by default', () => { + expect(PANGULAR_EXTENSION_IDS).toEqual([PINNED]); + expect(isAllowedExtensionOrigin(`chrome-extension://${PINNED}`)).toBe(true); + expect(isAllowedExtensionOrigin(`chrome-extension://${OURS}`)).toBe(false); + expect(isAllowedExtensionOrigin(`chrome-extension://${OTHER}`)).toBe(false); + }); + + it('keeps the pinned extension trusted next to a user list', () => { + const allowed = [`chrome-extension://${OURS}`]; + expect(isAllowedExtensionOrigin(`chrome-extension://${PINNED}`, allowed)).toBe(true); + expect(isAllowedExtensionOrigin(`chrome-extension://${OURS}`, allowed)).toBe(true); + }); + + it('accepts the published extension and refuses any other extension', () => { + expect(isAllowedExtensionOrigin(`chrome-extension://${OURS}`, [], [OURS])).toBe(true); + expect(isAllowedExtensionOrigin(`chrome-extension://${OTHER}`, [], [OURS])).toBe(false); + }); + + it('accepts an unpacked build listed in allowedOrigins', () => { + const allowed = [`chrome-extension://${OURS}`]; + expect(isAllowedExtensionOrigin(`chrome-extension://${OURS}`, allowed)).toBe(true); + expect(isAllowedExtensionOrigin(`chrome-extension://${OTHER}`, allowed)).toBe(false); + }); + + it('only matches the exact origin a browser sends', () => { + const allowed = [`chrome-extension://${OURS}`]; + for (const origin of [ + `chrome-extension://${OURS}/`, + `chrome-extension://${OURS.toUpperCase()}`, + `chrome-extension://${OURS}/ui/index.html`, + ]) { + expect(isAllowedExtensionOrigin(origin, allowed, [OURS])).toBe(false); + } + }); +}); + +describe('extension manifest key', () => { + const manifest = JSON.parse( + readFileSync(join(import.meta.dirname, '../../../../extension/manifest.json'), 'utf8'), + ) as { key?: string }; + + it('is an RSA SubjectPublicKeyInfo that gives the pinned extension ID', () => { + const der = Buffer.from(manifest.key ?? '', 'base64'); + expect(createPublicKey({ key: der, format: 'der', type: 'spki' }).asymmetricKeyType).toBe( + 'rsa', + ); + const id = [...createHash('sha256').update(der).digest('hex').slice(0, 32)] + .map((digit) => String.fromCharCode(97 + parseInt(digit, 16))) + .join(''); + expect(id).toBe(PINNED); + }); +}); diff --git a/packages/devtools/src/__tests__/extension-panel-bridge.test.ts b/packages/devtools/src/__tests__/extension-panel-bridge.test.ts index 951ac210..04a948aa 100644 --- a/packages/devtools/src/__tests__/extension-panel-bridge.test.ts +++ b/packages/devtools/src/__tests__/extension-panel-bridge.test.ts @@ -14,6 +14,7 @@ interface Setup { origin?: string; pageId?: () => string | null; storedPageId?: string | null; + extensionId?: string; granted?: boolean; grant?: boolean; fetch?: (url: string) => Promise; @@ -35,7 +36,9 @@ function open(setup: Setup = {}) { return granted; }); const chrome = { - runtime: { getURL: (path: string) => `chrome-extension://ext-id/${path}` }, + runtime: { + getURL: (path: string) => `chrome-extension://${setup.extensionId ?? 'ext-id'}/${path}`, + }, permissions: { contains: vi.fn(async () => granted), request }, devtools: { inspectedWindow: { @@ -264,7 +267,7 @@ describe('extension panel bridge', () => { }); await vi.advanceTimersByTimeAsync(500); expect(panel.message()).toBe( - 'The devtools server on http://192.168.1.20:5173 refused the request (403). It said: "Pangular Inspector only answers requests from this machine." Tried:', + 'The devtools server on http://192.168.1.20:5173 refused the request (403). It said: "Pangular Inspector only answers requests from this machine." If the page runs on this machine, add chrome-extension://ext-id to allowedOrigins to trust this extension. That does not change the rule that the server only answers this machine. Tried:', ); expect(panel.tried()).toContain( 'http://192.168.1.20:5173/__devframes/pangular/__connection.json (403)', @@ -275,6 +278,21 @@ describe('extension panel bridge', () => { expect(panel.docs.href).toMatch(/getting-started\/vite\.md#answers-only-your-machine$/); }); + it('leaves out the allowedOrigins hint for the extension with the pinned ID', async () => { + const panel = open({ + origin: 'http://192.168.1.20:5173', + extensionId: 'dcogniffeelebaolkkfbopmjcblhblfk', + fetch: (url) => + url.endsWith('/__devframes/pangular/__connection.json') + ? Promise.resolve(new Response('', { status: 403 })) + : Promise.resolve(new Response('', { status: 404 })), + }); + await vi.advanceTimersByTimeAsync(500); + expect(panel.message()).toBe( + 'The devtools server on http://192.168.1.20:5173 refused the request (403). Tried:', + ); + }); + it('restores the setup link after a refusal turns into no answer', async () => { let refuse = true; const panel = open({ diff --git a/packages/devtools/src/__tests__/hub.test.ts b/packages/devtools/src/__tests__/hub.test.ts index aa58ca54..86a120fd 100644 --- a/packages/devtools/src/__tests__/hub.test.ts +++ b/packages/devtools/src/__tests__/hub.test.ts @@ -83,7 +83,9 @@ describe('Pangular Inspector hub', () => { }); }); +const PANGULAR_EXTENSION = 'chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk'; const EXTENSION = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; +const OTHER_EXTENSION = 'chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba'; async function sseStatus( options: Parameters[0], @@ -135,13 +137,13 @@ describe('Pangular Inspector hub behind a web router', () => { }); describe('Pangular Inspector hub origins', () => { - it('accepts loopback pages and the Chrome extension by default, and nothing else', () => { + it('accepts loopback pages and the Pangular Inspector extension by default, and nothing else', () => { for (const origin of [ undefined, 'http://localhost:4000', 'http://127.0.0.1:4200', 'http://[::1]:3000', - EXTENSION, + PANGULAR_EXTENSION, ]) { expect(hubDefaultOrigins.isAllowed(origin)).toBe(true); } @@ -149,6 +151,9 @@ describe('Pangular Inspector hub origins', () => { 'https://evil.example', 'http://127.attacker.example', 'chrome-extension://', + `${PANGULAR_EXTENSION}/`, + EXTENSION, + OTHER_EXTENSION, 'moz-extension://abcdefghijklmnop', 'null', ]) { @@ -156,17 +161,27 @@ describe('Pangular Inspector hub origins', () => { } }); - it('lets the Chrome extension panel open the SSE stream by default', async () => { - expect(await sseStatus({}, EXTENSION)).toBe(200); + it('lets the Pangular Inspector extension open the SSE stream by default, and refuses other extensions', async () => { + expect(await sseStatus({}, PANGULAR_EXTENSION)).toBe(200); + expect(await sseStatus({}, OTHER_EXTENSION)).toBe(403); expect(await sseStatus({}, 'http://localhost:4000')).toBe(200); expect(await sseStatus({}, 'https://evil.example')).toBe(403); }); + it('accepts an unpacked extension listed in allowedOrigins and refuses other extensions', async () => { + const unpacked = { allowedOrigins: [EXTENSION] }; + expect(await sseStatus(unpacked, EXTENSION)).toBe(200); + expect(await sseStatus(unpacked, OTHER_EXTENSION)).toBe(403); + expect(await sseStatus(unpacked, PANGULAR_EXTENSION)).toBe(200); + expect(await sseStatus(unpacked, 'http://localhost:4000')).toBe(200); + }); + it('keeps an explicit allowedOrigins setting as given', async () => { const tunnel = { allowedOrigins: ['https://tunnel.example'] }; expect(await sseStatus(tunnel, 'https://tunnel.example')).toBe(200); expect(await sseStatus(tunnel, EXTENSION)).toBe(403); - expect(await sseStatus({ allowedOrigins: [EXTENSION] }, EXTENSION)).toBe(200); + expect(await sseStatus(tunnel, PANGULAR_EXTENSION)).toBe(200); + expect(await sseStatus({ allowedOrigins: [] }, PANGULAR_EXTENSION)).toBe(200); expect(await sseStatus({ allowedOrigins: false }, 'https://evil.example')).toBe(200); }); }); diff --git a/packages/devtools/src/__tests__/vite-auth.test.ts b/packages/devtools/src/__tests__/vite-auth.test.ts index a0cda387..5cf4842d 100644 --- a/packages/devtools/src/__tests__/vite-auth.test.ts +++ b/packages/devtools/src/__tests__/vite-auth.test.ts @@ -34,7 +34,12 @@ describe('allowsRemoteOrigins', () => { expect( allowsRemoteOrigins({ allowedHosts: ['localhost', '.localhost', '127.0.0.1', '::1', '[::1]'], - allowedOrigins: ['http://localhost:4200', 'https://127.0.0.1:5173', 'http://[::1]:3000'], + allowedOrigins: [ + 'http://localhost:4200', + 'https://127.0.0.1:5173', + 'http://[::1]:3000', + 'chrome-extension://abcdefghijklmnopabcdefghijklmnop', + ], }), ).toBe(false); }); @@ -142,4 +147,33 @@ describe('pangularVite allowedOrigins', () => { expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledWith(expect.stringContaining('https://evil.example')); }); + it('accepts the Pangular Inspector extension with no allowedOrigins, without the one-time code', () => { + const { auth, status } = plugin([]); + expect(auth).toBe(false); + expect(status('chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk')).toBe('next'); + }); + + it('refuses a Chrome extension that is not listed', () => { + const { status } = plugin([]); + expect(status('chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba')).toBe(403); + expect(status('chrome-extension://abcdefghijklmnopabcdefghijklmnop')).toBe(403); + }); + + it('accepts an unpacked extension ID listed in allowedOrigins, without the one-time code', () => { + const selfBuilt = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; + const { auth, warn, status } = plugin([selfBuilt]); + expect(auth).toBe(false); + expect(warn).not.toHaveBeenCalled(); + expect(status(selfBuilt)).toBe('next'); + expect(status('chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk')).toBe('next'); + expect(status('chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba')).toBe(403); + }); + + it('reads an extension entry with a trailing slash or uppercase ID as its origin', () => { + const { warn, status } = plugin(['chrome-extension://ABCDEFGHIJKLMNOPABCDEFGHIJKLMNOP/']); + expect(status('chrome-extension://abcdefghijklmnopabcdefghijklmnop')).toBe('next'); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('"chrome-extension://abcdefghijklmnopabcdefghijklmnop"'), + ); + }); }); diff --git a/packages/devtools/src/__tests__/vite-upgrade-guard.test.ts b/packages/devtools/src/__tests__/vite-upgrade-guard.test.ts index 570fcee9..f9f763e6 100644 --- a/packages/devtools/src/__tests__/vite-upgrade-guard.test.ts +++ b/packages/devtools/src/__tests__/vite-upgrade-guard.test.ts @@ -2,12 +2,17 @@ import { Server } from 'node:http'; import { describe, expect, it, vi } from 'vitest'; import { hubOriginRegistry, + hubOriginRegistryFor, hubRequestGate, hubUpgradeListener, isAllowedHubOrigin, isHubPath, } from '../vite.ts'; +const PANGULAR_EXTENSION = 'chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk'; +const EXTENSION = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; +const OTHER_EXTENSION = 'chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba'; + function upgrade(server: Server, remoteAddress: string, url = '/__devframes/ws', origin?: string) { const socket = { destroy: vi.fn() }; const headers = origin === undefined ? {} : { origin }; @@ -70,7 +75,13 @@ describe('hubUpgradeListener', () => { const { server, hub } = guarded(); const evil = upgrade(server, '127.0.0.1', '/__devframes/__ws', 'https://evil.example'); expect(evil.destroy).toHaveBeenCalled(); - for (const origin of ['http://127.attacker.example', 'null', 'file://']) { + for (const origin of [ + 'http://127.attacker.example', + 'null', + 'file://', + EXTENSION, + OTHER_EXTENSION, + ]) { expect(upgrade(server, '127.0.0.1', '/__devframes/__ws', origin).destroy).toHaveBeenCalled(); } expect(hub).not.toHaveBeenCalled(); @@ -79,7 +90,7 @@ describe('hubUpgradeListener', () => { 'http://localhost:5173', 'https://127.0.0.1:4200', 'http://[::1]:3000', - 'chrome-extension://abcdefghijklmnop', + PANGULAR_EXTENSION, undefined, ]) { expect( @@ -104,14 +115,29 @@ describe('hubUpgradeListener', () => { }); describe('hub origin policy', () => { - it('allows loopback http(s) pages and the extension, and rejects other sites', () => { + it('allows loopback http(s) pages and the Pangular Inspector extension, and rejects other sites and extensions', () => { expect(isAllowedHubOrigin(undefined)).toBe(true); expect(isAllowedHubOrigin('http://localhost:5173')).toBe(true); - expect(isAllowedHubOrigin('chrome-extension://abcdefghijklmnop')).toBe(true); + expect(isAllowedHubOrigin(PANGULAR_EXTENSION)).toBe(true); + expect(hubOriginRegistry.isAllowed(PANGULAR_EXTENSION)).toBe(true); + expect(isAllowedHubOrigin(EXTENSION)).toBe(false); + expect(isAllowedHubOrigin('chrome-extension://abcdefghijklmnop')).toBe(false); expect(isAllowedHubOrigin('https://evil.example')).toBe(false); expect(isAllowedHubOrigin('ws://localhost:5173')).toBe(false); expect(hubOriginRegistry.isAllowed('https://evil.example')).toBe(false); expect(hubOriginRegistry.isAllowed('http://127.0.0.1:9777')).toBe(true); + expect(hubOriginRegistry.isAllowed(EXTENSION)).toBe(false); + }); + + it('accepts the extension IDs listed in allowedOrigins next to the Pangular Inspector extension', () => { + const policy = { allowedOrigins: [EXTENSION] }; + expect(isAllowedHubOrigin(EXTENSION, policy)).toBe(true); + expect(isAllowedHubOrigin(OTHER_EXTENSION, policy)).toBe(false); + expect(isAllowedHubOrigin(`${EXTENSION}/`, policy)).toBe(false); + expect(hubOriginRegistryFor(policy).isAllowed(EXTENSION)).toBe(true); + expect(hubOriginRegistryFor(policy).isAllowed(OTHER_EXTENSION)).toBe(false); + expect(isAllowedHubOrigin(PANGULAR_EXTENSION, policy)).toBe(true); + expect(hubOriginRegistryFor(policy).isAllowed(PANGULAR_EXTENSION)).toBe(true); }); }); diff --git a/packages/devtools/src/extension-origin.ts b/packages/devtools/src/extension-origin.ts new file mode 100644 index 00000000..04795b8f --- /dev/null +++ b/packages/devtools/src/extension-origin.ts @@ -0,0 +1,42 @@ +/** + * IDs of Pangular Inspector extension builds, trusted without any + * `allowedOrigins` entry. The `key` in `extension/manifest.json` fixes the ID. + */ +export const PANGULAR_EXTENSION_IDS: readonly string[] = ['dcogniffeelebaolkkfbopmjcblhblfk']; + +export const PANGULAR_EXTENSION_ORIGINS: readonly string[] = PANGULAR_EXTENSION_IDS.map( + (id) => `chrome-extension://${id}`, +); + +const EXTENSION_ID = /^[a-p]{32}$/; + +/** + * The canonical `chrome-extension://` origin of `value`, or `undefined` + * when it is not a Chrome extension origin with a well-formed ID. + */ +export function extensionOrigin(value: string): string | undefined { + let url: URL; + try { + url = new URL(value); + } catch { + return undefined; + } + if (url.protocol !== 'chrome-extension:') return undefined; + const id = url.hostname.toLowerCase(); + return EXTENSION_ID.test(id) ? `chrome-extension://${id}` : undefined; +} + +/** + * Whether `origin` is a Chrome extension the devtools trust: a published + * Pangular Inspector build, or one listed in `allowedOrigins`. + */ +export function isAllowedExtensionOrigin( + origin: string, + allowedOrigins: readonly string[] = [], + publishedIds: readonly string[] = PANGULAR_EXTENSION_IDS, +): boolean { + const canonical = extensionOrigin(origin); + if (!canonical || canonical !== origin) return false; + const id = canonical.slice('chrome-extension://'.length); + return publishedIds.includes(id) || allowedOrigins.includes(canonical); +} diff --git a/packages/devtools/src/hub.ts b/packages/devtools/src/hub.ts index 131f8c15..1a6afb1d 100644 --- a/packages/devtools/src/hub.ts +++ b/packages/devtools/src/hub.ts @@ -8,6 +8,7 @@ import type { InitHubOptions } from '@devframes/hub/initiate'; import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'; import { isAllowedOrigin } from 'devframe/utils/origin'; import { createPangular } from './devframe.ts'; +import { PANGULAR_EXTENSION_ORIGINS, isAllowedExtensionOrigin } from './extension-origin.ts'; import { pickPangularConfig, type PangularConfig } from './config.ts'; import { PANGULAR_LOGO_DATA_URI } from './brand.ts'; import pkg from '../package.json' with { type: 'json' }; @@ -48,22 +49,21 @@ function hubUi() { }; } -function isExtensionOrigin(origin: string): boolean { - try { - const url = new URL(origin); - return url.protocol === 'chrome-extension:' && url.hostname !== ''; - } catch { - return false; - } -} - export const hubDefaultOrigins: WsOriginRegistry = { token: '', registerFromUrl: () => undefined, isAllowed: (origin: string | undefined) => - (origin !== undefined && isExtensionOrigin(origin)) || isAllowedOrigin(origin, []), + (origin !== undefined && isAllowedExtensionOrigin(origin)) || isAllowedOrigin(origin, []), }; +function hubAllowedOrigins( + allowedOrigins: InitHubOptions['allowedOrigins'], +): NonNullable { + if (allowedOrigins === undefined) return hubDefaultOrigins; + if (Array.isArray(allowedOrigins)) return [...allowedOrigins, ...PANGULAR_EXTENSION_ORIGINS]; + return allowedOrigins; +} + type PangularHub = ReturnType; interface HubRegistry { @@ -106,7 +106,7 @@ export function initPangularHub(options: PangularHubOptions = {}): PangularHub { version: pkg.version, base: PANGULAR_HUB_BASE, ...rest, - allowedOrigins: rest.allowedOrigins ?? hubDefaultOrigins, + allowedOrigins: hubAllowedOrigins(rest.allowedOrigins), mcp: hubMcpFor(rest), devframes: [createPangular(config)], ui: hubUi(), diff --git a/packages/devtools/src/vite.ts b/packages/devtools/src/vite.ts index d2e373a5..a2ece18e 100644 --- a/packages/devtools/src/vite.ts +++ b/packages/devtools/src/vite.ts @@ -9,6 +9,7 @@ import { analogMiddleware, setDevOrigin } from './analog-server-log.ts'; import { analogConfig, setAnalogRoot } from './rpc/analog-scan.ts'; import { stopAnalog } from './rpc/analog-register.ts'; import { httpRegistry } from './http-rules.ts'; +import { extensionOrigin, isAllowedExtensionOrigin } from './extension-origin.ts'; import { pickPangularConfig, resolvePangularConfig, type PangularConfig } from './config.ts'; export type { PangularConfig } from './config.ts'; @@ -47,7 +48,9 @@ export function isAllowedHubOrigin( if (origin === undefined) return true; try { const url = new URL(origin); - if (url.protocol === 'chrome-extension:') return url.hostname !== ''; + if (url.protocol === 'chrome-extension:') { + return isAllowedExtensionOrigin(origin, policy.allowedOrigins); + } if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; if (policy.allowedOrigins?.includes(url.origin)) return true; return isLoopbackHostname(url.hostname) || hostAllowed(url.hostname, policy.allowedHosts); @@ -67,6 +70,14 @@ function isLoopbackOrigin(origin: string): boolean { } } +function webOrigin(entry: string): string { + try { + return new URL(entry).origin; + } catch { + return 'null'; + } +} + /** * Reduces each `allowedOrigins` entry to the origin a browser sends: no path or * trailing slash, and a lowercase host. Entries that are not URLs are dropped. @@ -77,12 +88,7 @@ export function normalizeAllowedOrigins( ): string[] { const origins: string[] = []; for (const entry of entries ?? []) { - let origin: string; - try { - origin = new URL(entry).origin; - } catch { - origin = 'null'; - } + const origin = extensionOrigin(entry) ?? webOrigin(entry); if (origin === 'null') { warn( `[pangular] Ignoring allowedOrigins entry "${entry}": it is not an origin such as https://tunnel.example.`, @@ -105,7 +111,9 @@ export function allowsRemoteOrigins(policy: HubOriginPolicy = {}): boolean { if (hosts.some((entry) => !isLoopbackHostname(entry.replace(/^\./, '').toLowerCase()))) { return true; } - return (policy.allowedOrigins ?? []).some((origin) => !isLoopbackOrigin(origin)); + return (policy.allowedOrigins ?? []).some( + (origin) => !isLoopbackOrigin(origin) && !extensionOrigin(origin), + ); } export function hubAuthFor(policy: HubOriginPolicy, auth?: boolean): boolean { diff --git a/scripts/extension-zip.mjs b/scripts/extension-zip.mjs new file mode 100644 index 00000000..25c53570 --- /dev/null +++ b/scripts/extension-zip.mjs @@ -0,0 +1,41 @@ +import { execFileSync } from 'node:child_process'; +import { createPublicKey } from 'node:crypto'; +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); +const out = join(root, 'dist/pangular-inspector-extension.zip'); +const keyPath = process.env.PANGULAR_EXTENSION_KEY; + +const stage = mkdtempSync(join(tmpdir(), 'pangular-extension-')); +try { + cpSync(join(root, 'extension'), stage, { + recursive: true, + filter: (src) => !src.endsWith('.DS_Store'), + }); + const manifestPath = join(stage, 'manifest.json'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + const manifestKey = manifest.key; + delete manifest.key; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + if (keyPath) { + if (!existsSync(keyPath)) throw new Error(`PANGULAR_EXTENSION_KEY not found: ${keyPath}`); + const publicKey = createPublicKey(readFileSync(keyPath)) + .export({ type: 'spki', format: 'der' }) + .toString('base64'); + if (publicKey !== manifestKey) { + throw new Error( + `PANGULAR_EXTENSION_KEY does not match the key in extension/manifest.json, so the store would give the extension another ID.`, + ); + } + cpSync(keyPath, join(stage, 'key.pem')); + } + rmSync(out, { force: true }); + execFileSync('zip', ['-qr', out, '.'], { cwd: stage, stdio: 'inherit' }); + console.log( + `Wrote ${out}${keyPath ? ' with key.pem for the first Web Store upload' : ' without a key (fine for updates)'}`, + ); +} finally { + rmSync(stage, { recursive: true, force: true }); +}