From 46f94b3f6f1b980225cb21667fe04354da92dcba Mon Sep 17 00:00:00 2001 From: Kam Date: Tue, 6 Oct 2026 20:58:52 +0300 Subject: [PATCH 1/3] fix(hub): trust only listed Chrome extension origins The hub and the Vite plugin accepted any chrome-extension:// origin, so any installed extension could read live state and call write RPCs, with no one-time code on loopback. Extension origins are now trusted only when their exact origin is a published Pangular Inspector ID or is listed in allowedOrigins, and the extension's refusal message names the origin to add. Refs #157 --- .../getting-started/chrome-extension.md | 25 +++++++- .../content/getting-started/configuration.md | 2 + .../src/content/getting-started/express.md | 16 ++--- apps/docs/src/content/getting-started/vite.md | 14 ++-- apps/docs/src/content/guides/analog.md | 12 ++-- apps/docs/src/content/security.md | 22 ++++--- extension/panel-bridge.js | 7 +- .../src/__tests__/extension-origin.test.ts | 64 +++++++++++++++++++ .../__tests__/extension-panel-bridge.test.ts | 2 +- packages/devtools/src/__tests__/hub.test.ts | 18 ++++-- .../devtools/src/__tests__/vite-auth.test.ts | 28 +++++++- .../src/__tests__/vite-upgrade-guard.test.ts | 24 +++++-- packages/devtools/src/extension-origin.ts | 38 +++++++++++ packages/devtools/src/hub.ts | 12 +--- packages/devtools/src/vite.ts | 24 ++++--- 15 files changed, 244 insertions(+), 64 deletions(-) create mode 100644 packages/devtools/src/__tests__/extension-origin.test.ts create mode 100644 packages/devtools/src/extension-origin.ts diff --git a/apps/docs/src/content/getting-started/chrome-extension.md b/apps/docs/src/content/getting-started/chrome-extension.md index c442ec84..515c2059 100644 --- a/apps/docs/src/content/getting-started/chrome-extension.md +++ b/apps/docs/src/content/getting-started/chrome-extension.md @@ -46,6 +46,9 @@ The Chrome extension adds a panel named **Pangular Inspector** to Chrome DevTool Click Load unpacked and select the extension/ directory. + + Copy the ID from the extension card in chrome://extensions. Add chrome-extension://<id> to allowedOrigins in the Vite plugin or the Express hub. See Server origin. + The Pangular Inspector panel appears next to the built-in panels. @@ -129,7 +132,25 @@ 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://`. The Vite plugin and the Express hub refuse every Chrome extension origin unless you list it in `allowedOrigins`. Any installed extension can send requests to a loopback host, so the server trusts only the extension IDs you name. + +The extension has no fixed ID. Chrome gives an unpacked extension an ID based on the folder it was loaded from, so the ID stays the same until you load it from another folder. Copy it from the extension card in `chrome://extensions`: + +```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 the panel, the panel names its own origin in the message. See [Access and redaction](../security.md#chrome-extension). ### Content scripts @@ -148,7 +169,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. The Vite plugin and the Express hub refuse an extension origin that is not in 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..c90c8537 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. To use the [Chrome extension](./chrome-extension.md#server-origin), add its `chrome-extension://` origin to `allowedOrigins` in the Express hub or the Vite plugin. No extension is trusted without it. + ### 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..0e4ef431 100644 --- a/apps/docs/src/content/getting-started/express.md +++ b/apps/docs/src/content/getting-started/express.md @@ -95,13 +95,13 @@ With `ws: {sidecar: true}`, the WebSocket runs on its own port, picked automatic `initPangularHub()` accepts the options of `initHub()` from `@devframes/hub`, apart from `devframes` and `ui`. These are the ones you are most likely to set: -| Option | Default | What it does | -| ---------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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. | -| `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). | +| Option | Default | What it does | +| ---------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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 | Extra origins allowed to open the WebSocket, such as `chrome-extension://` for the Chrome extension. `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 can open the WebSocket. A list in `allowedOrigins` keeps loopback origins and adds its entries. To use the [Chrome extension](./chrome-extension.md), add `chrome-extension://` to the list, 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..28bd7f9c 100644 --- a/apps/docs/src/content/getting-started/vite.md +++ b/apps/docs/src/content/getting-started/vite.md @@ -101,12 +101,12 @@ pangular({ }); ``` -| Option | Default | What it does | -| ---------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------ | -| `base` | `'/__devframes/'` | Where the hub is mounted. | -| `apiPrefix` | Analog's `apiPrefix`, or `'api'` | The prefix of your server routes, used to classify API calls. | -| `allowedOrigins` | none | Extra exact origins allowed to reach the devtools, for example a tunnel. | -| `auth` | on if a non-loopback host or origin is allowed, otherwise off | Whether the devtools ask for the one-time code. | +| Option | Default | What it does | +| ---------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `base` | `'/__devframes/'` | Where the hub is mounted. | +| `apiPrefix` | Analog's `apiPrefix`, or `'api'` | The prefix of your server routes, used to classify API calls. | +| `allowedOrigins` | none | Extra exact origins allowed to reach the devtools, for example a tunnel or the Chrome extension. | +| `auth` | on if a non-loopback host or origin is allowed, otherwise off | Whether the devtools ask for the one-time code. | The plugin also takes the devtools options, such as `inspectors`, `agent`, `actions`, `redaction` and `limits`. See [Configuration](./configuration.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 refuses every Chrome extension that is not listed, so add the ID of the [Chrome extension](./chrome-extension.md#server-origin) to use its panel. 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/guides/analog.md b/apps/docs/src/content/guides/analog.md index b7a4f360..9f03c165 100644 --- a/apps/docs/src/content/guides/analog.md +++ b/apps/docs/src/content/guides/analog.md @@ -84,12 +84,12 @@ The plugin runs on the dev server only (`apply: 'serve'`). Production builds do All four are optional. -| Option | Default | What it does | -| ---------------- | ---------------------------------------------- | ------------------------------------------------------ | -| `base` | `/__devframes/` | Where the hub is mounted. | -| `apiPrefix` | Read from your Analog config | The API prefix used to tell API calls from page calls. | -| `allowedOrigins` | none | Extra page origins accepted next to localhost. | -| `auth` | on if a non-loopback host or origin is allowed | Whether the devtools ask for the one-time code. | +| Option | Default | What it does | +| ---------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | +| `base` | `/__devframes/` | Where the hub is mounted. | +| `apiPrefix` | Read from your Analog config | The API prefix used to tell API calls from page calls. | +| `allowedOrigins` | none | Extra page origins accepted next to localhost, such as the Chrome extension. | +| `auth` | on if a non-loopback host or origin is allowed | Whether the devtools ask for the one-time code. | The plugin also takes the devtools options. See [Vite and Analog](../getting-started/vite.md#options) and [Configuration](../getting-started/configuration.md). diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md index 6fcda6c6..e38d7381 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, an entry in allowedOrigins (a Chrome extension included) 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. Both on by default. The Chrome extension needs its origin in allowedOrigins. 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 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, 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, 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. To use the Chrome extension, add its origin to the list. 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,9 @@ 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 refuse every extension origin by default, and accept only the ones listed in `allowedOrigins`. Each entry names one extension, such as `chrome-extension://abcdefghijklmnopabcdefghijklmnop`. 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/panel-bridge.js b/extension/panel-bridge.js index 68cc4d77..7eb68d64 100644 --- a/extension/panel-bridge.js +++ b/extension/panel-bridge.js @@ -97,8 +97,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 + ? ` To trust this extension, add ${extension} to allowedOrigins.` + : ''; 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/packages/devtools/src/__tests__/extension-origin.test.ts b/packages/devtools/src/__tests__/extension-origin.test.ts new file mode 100644 index 00000000..9a070257 --- /dev/null +++ b/packages/devtools/src/__tests__/extension-origin.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + PANGULAR_EXTENSION_IDS, + extensionOrigin, + isAllowedExtensionOrigin, +} from '../extension-origin.ts'; + +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 no extension until it has a published ID', () => { + expect(PANGULAR_EXTENSION_IDS).toEqual([]); + expect(isAllowedExtensionOrigin(`chrome-extension://${OURS}`)).toBe(false); + }); + + 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); + } + }); +}); diff --git a/packages/devtools/src/__tests__/extension-panel-bridge.test.ts b/packages/devtools/src/__tests__/extension-panel-bridge.test.ts index 951ac210..b727761e 100644 --- a/packages/devtools/src/__tests__/extension-panel-bridge.test.ts +++ b/packages/devtools/src/__tests__/extension-panel-bridge.test.ts @@ -264,7 +264,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." To trust this extension, add chrome-extension://ext-id to allowedOrigins. Tried:', ); expect(panel.tried()).toContain( 'http://192.168.1.20:5173/__devframes/pangular/__connection.json (403)', diff --git a/packages/devtools/src/__tests__/hub.test.ts b/packages/devtools/src/__tests__/hub.test.ts index aa58ca54..6db2a896 100644 --- a/packages/devtools/src/__tests__/hub.test.ts +++ b/packages/devtools/src/__tests__/hub.test.ts @@ -84,6 +84,7 @@ describe('Pangular Inspector hub', () => { }); const EXTENSION = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; +const OTHER_EXTENSION = 'chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba'; async function sseStatus( options: Parameters[0], @@ -135,13 +136,12 @@ 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 by default, and no Chrome extension without a known ID', () => { for (const origin of [ undefined, 'http://localhost:4000', 'http://127.0.0.1:4200', 'http://[::1]:3000', - EXTENSION, ]) { expect(hubDefaultOrigins.isAllowed(origin)).toBe(true); } @@ -149,6 +149,8 @@ describe('Pangular Inspector hub origins', () => { 'https://evil.example', 'http://127.attacker.example', 'chrome-extension://', + EXTENSION, + OTHER_EXTENSION, 'moz-extension://abcdefghijklmnop', 'null', ]) { @@ -156,17 +158,23 @@ 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('refuses an unknown Chrome extension on the SSE stream by default', async () => { + 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, '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({ 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..e1d1eab6 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,25 @@ describe('pangularVite allowedOrigins', () => { expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledWith(expect.stringContaining('https://evil.example')); }); + it('refuses a Chrome extension that is not listed', () => { + const { status } = plugin([]); + expect(status('chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba')).toBe(403); + }); + + it('accepts an unpacked extension ID listed in allowedOrigins, without the one-time code', () => { + const ours = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; + const { auth, warn, status } = plugin([ours]); + expect(auth).toBe(false); + expect(warn).not.toHaveBeenCalled(); + expect(status(ours)).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..54d87e48 100644 --- a/packages/devtools/src/__tests__/vite-upgrade-guard.test.ts +++ b/packages/devtools/src/__tests__/vite-upgrade-guard.test.ts @@ -2,12 +2,16 @@ import { Server } from 'node:http'; import { describe, expect, it, vi } from 'vitest'; import { hubOriginRegistry, + hubOriginRegistryFor, hubRequestGate, hubUpgradeListener, isAllowedHubOrigin, isHubPath, } from '../vite.ts'; +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 +74,7 @@ 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://', OTHER_EXTENSION]) { expect(upgrade(server, '127.0.0.1', '/__devframes/__ws', origin).destroy).toHaveBeenCalled(); } expect(hub).not.toHaveBeenCalled(); @@ -79,14 +83,13 @@ describe('hubUpgradeListener', () => { 'http://localhost:5173', 'https://127.0.0.1:4200', 'http://[::1]:3000', - 'chrome-extension://abcdefghijklmnop', undefined, ]) { expect( upgrade(server, '127.0.0.1', '/__devframes/__ws', origin).destroy, ).not.toHaveBeenCalled(); } - expect(hub).toHaveBeenCalledTimes(5); + expect(hub).toHaveBeenCalledTimes(4); }); it('normalizes the path before deciding whether an upgrade targets the hub', () => { @@ -104,14 +107,25 @@ 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 rejects other sites and unknown extensions', () => { expect(isAllowedHubOrigin(undefined)).toBe(true); expect(isAllowedHubOrigin('http://localhost:5173')).toBe(true); - expect(isAllowedHubOrigin('chrome-extension://abcdefghijklmnop')).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 only the extension IDs listed in allowedOrigins', () => { + 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); }); }); diff --git a/packages/devtools/src/extension-origin.ts b/packages/devtools/src/extension-origin.ts new file mode 100644 index 00000000..5134b140 --- /dev/null +++ b/packages/devtools/src/extension-origin.ts @@ -0,0 +1,38 @@ +/** + * IDs of published Pangular Inspector extension builds, trusted without any + * `allowedOrigins` entry. Empty until the extension has a fixed ID. + */ +export const PANGULAR_EXTENSION_IDS: readonly string[] = []; + +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..be4e2681 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 { 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,20 +49,11 @@ 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, []), }; type PangularHub = ReturnType; 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 { From 2d1989cc0f8fc1dff61b3cf29971feaadb3c0cc0 Mon Sep 17 00:00:00 2001 From: Kam Date: Wed, 7 Oct 2026 12:07:20 +0300 Subject: [PATCH 2/3] fix(extension): pin the extension ID with a manifest key The server trusts only listed extension origins, but unpacked builds of our extension got a random ID, so every user would have had to list it. A manifest key now fixes the ID to dcogniffeelebaolkkfbopmjcblhblfk, which the hub and the Vite plugin trust by default, even next to a user's own allowedOrigins list. extension:zip drops the key from the store manifest and can add key.pem for the first Web Store upload. --- .../content/contributing/chrome-extension.md | 19 +++++++++-- .../getting-started/chrome-extension.md | 13 ++++---- .../content/getting-started/configuration.md | 2 +- .../src/content/getting-started/express.md | 16 ++++----- apps/docs/src/content/getting-started/vite.md | 14 ++++---- apps/docs/src/content/guides/analog.md | 12 +++---- apps/docs/src/content/security.md | 22 +++++++------ extension/manifest.json | 1 + extension/panel-bridge.js | 3 +- package.json | 2 +- .../src/__tests__/extension-origin.test.ts | 33 +++++++++++++++++-- .../__tests__/extension-panel-bridge.test.ts | 20 ++++++++++- packages/devtools/src/__tests__/hub.test.ts | 11 +++++-- .../devtools/src/__tests__/vite-auth.test.ts | 14 ++++++-- .../src/__tests__/vite-upgrade-guard.test.ts | 20 ++++++++--- packages/devtools/src/extension-origin.ts | 10 ++++-- packages/devtools/src/hub.ts | 12 +++++-- scripts/extension-zip.mjs | 31 +++++++++++++++++ 18 files changed, 195 insertions(+), 60 deletions(-) create mode 100644 scripts/extension-zip.mjs diff --git a/apps/docs/src/content/contributing/chrome-extension.md b/apps/docs/src/content/contributing/chrome-extension.md index d1bdaced..b6cb56e1 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`. 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 515c2059..a9f5db0b 100644 --- a/apps/docs/src/content/getting-started/chrome-extension.md +++ b/apps/docs/src/content/getting-started/chrome-extension.md @@ -46,9 +46,6 @@ The Chrome extension adds a panel named **Pangular Inspector** to Chrome DevTool Click Load unpacked and select the extension/ directory. - - Copy the ID from the extension card in chrome://extensions. Add chrome-extension://<id> to allowedOrigins in the Vite plugin or the Express hub. See Server origin. - The Pangular Inspector panel appears next to the built-in panels. @@ -134,9 +131,11 @@ Granting the extension a host doesn't change what the devtools server accepts. T ### Server origin -The panel sends requests from its own origin, `chrome-extension://`. The Vite plugin and the Express hub refuse every Chrome extension origin unless you list it in `allowedOrigins`. Any installed extension can send requests to a loopback host, so the server trusts only the extension IDs you name. +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. -The extension has no fixed ID. Chrome gives an unpacked extension an ID based on the folder it was loaded from, so the ID stays the same until you load it from another folder. Copy it from the extension card in `chrome://extensions`: +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 @@ -150,7 +149,7 @@ const devtools = initPangularHub({ }); ``` -In the Vite plugin, an extension entry does not turn the one-time code on. If the server refuses the panel, the panel names its own origin in the message. See [Access and redaction](../security.md#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 @@ -169,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 and the Express hub refuse an extension origin that is not in 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 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 c90c8537..b0367ce8 100644 --- a/apps/docs/src/content/getting-started/configuration.md +++ b/apps/docs/src/content/getting-started/configuration.md @@ -23,7 +23,7 @@ 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. To use the [Chrome extension](./chrome-extension.md#server-origin), add its `chrome-extension://` origin to `allowedOrigins` in the Express hub or the Vite plugin. No extension is trusted without it. +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 diff --git a/apps/docs/src/content/getting-started/express.md b/apps/docs/src/content/getting-started/express.md index 0e4ef431..135ecc2f 100644 --- a/apps/docs/src/content/getting-started/express.md +++ b/apps/docs/src/content/getting-started/express.md @@ -95,13 +95,13 @@ With `ws: {sidecar: true}`, the WebSocket runs on its own port, picked automatic `initPangularHub()` accepts the options of `initHub()` from `@devframes/hub`, apart from `devframes` and `ui`. These are the ones you are most likely to set: -| Option | Default | What it does | -| ---------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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 | Extra origins allowed to open the WebSocket, such as `chrome-extension://` for the Chrome extension. `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). | +| Option | Default | What it does | +| ---------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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, 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 can open the WebSocket. A list in `allowedOrigins` keeps loopback origins and adds its entries. To use the [Chrome extension](./chrome-extension.md), 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 28bd7f9c..5247ac58 100644 --- a/apps/docs/src/content/getting-started/vite.md +++ b/apps/docs/src/content/getting-started/vite.md @@ -101,12 +101,12 @@ pangular({ }); ``` -| Option | Default | What it does | -| ---------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| `base` | `'/__devframes/'` | Where the hub is mounted. | -| `apiPrefix` | Analog's `apiPrefix`, or `'api'` | The prefix of your server routes, used to classify API calls. | -| `allowedOrigins` | none | Extra exact origins allowed to reach the devtools, for example a tunnel or the Chrome extension. | -| `auth` | on if a non-loopback host or origin is allowed, otherwise off | Whether the devtools ask for the one-time code. | +| Option | Default | What it does | +| ---------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `base` | `'/__devframes/'` | Where the hub is mounted. | +| `apiPrefix` | Analog's `apiPrefix`, or `'api'` | The prefix of your server routes, used to classify API calls. | +| `allowedOrigins` | none | Extra exact origins allowed to reach the devtools, for example a tunnel. | +| `auth` | on if a non-loopback host or origin is allowed, otherwise off | Whether the devtools ask for the one-time code. | The plugin also takes the devtools options, such as `inspectors`, `agent`, `actions`, `redaction` and `limits`. See [Configuration](./configuration.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` or `chrome-extension://`. The request itself must still come from a loopback address. The plugin refuses every Chrome extension that is not listed, so add the ID of the [Chrome extension](./chrome-extension.md#server-origin) to use its panel. An extension entry does not turn the one-time code on. +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/guides/analog.md b/apps/docs/src/content/guides/analog.md index 9f03c165..b7a4f360 100644 --- a/apps/docs/src/content/guides/analog.md +++ b/apps/docs/src/content/guides/analog.md @@ -84,12 +84,12 @@ The plugin runs on the dev server only (`apply: 'serve'`). Production builds do All four are optional. -| Option | Default | What it does | -| ---------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | -| `base` | `/__devframes/` | Where the hub is mounted. | -| `apiPrefix` | Read from your Analog config | The API prefix used to tell API calls from page calls. | -| `allowedOrigins` | none | Extra page origins accepted next to localhost, such as the Chrome extension. | -| `auth` | on if a non-loopback host or origin is allowed | Whether the devtools ask for the one-time code. | +| Option | Default | What it does | +| ---------------- | ---------------------------------------------- | ------------------------------------------------------ | +| `base` | `/__devframes/` | Where the hub is mounted. | +| `apiPrefix` | Read from your Analog config | The API prefix used to tell API calls from page calls. | +| `allowedOrigins` | none | Extra page origins accepted next to localhost. | +| `auth` | on if a non-loopback host or origin is allowed | Whether the devtools ask for the one-time code. | The plugin also takes the devtools options. See [Vite and Analog](../getting-started/vite.md#options) and [Configuration](../getting-started/configuration.md). diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md index e38d7381..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, an entry in allowedOrigins (a Chrome extension included) 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. Both on by default. The Chrome extension needs its origin in allowedOrigins. + 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 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, 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, 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. To use the Chrome extension, add its origin to the list. See [Chrome extension](#chrome-extension). +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. @@ -123,7 +123,9 @@ On any other host, the panel doesn't send a request until you click **Allow acce 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 refuse every extension origin by default, and accept only the ones listed in `allowedOrigins`. Each entry names one extension, such as `chrome-extension://abcdefghijklmnopabcdefghijklmnop`. 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). +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 7eb68d64..48b293ad 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; @@ -99,7 +100,7 @@ async function detectConnection() { const reason = refused.text ? ` It said: "${refused.text}"` : ''; const extension = chrome.runtime.getURL('').replace(/\/$/, ''); const hint = - refused.status === 403 + refused.status === 403 && extension !== PINNED_ORIGIN ? ` To trust this extension, add ${extension} to allowedOrigins.` : ''; showStatus( 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 index 9a070257..b2588feb 100644 --- a/packages/devtools/src/__tests__/extension-origin.test.ts +++ b/packages/devtools/src/__tests__/extension-origin.test.ts @@ -1,3 +1,6 @@ +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, @@ -5,6 +8,7 @@ import { isAllowedExtensionOrigin, } from '../extension-origin.ts'; +const PINNED = 'dcogniffeelebaolkkfbopmjcblhblfk'; const OURS = 'abcdefghijklmnopabcdefghijklmnop'; const OTHER = 'ponmlkjihgfedcbaponmlkjihgfedcba'; @@ -35,9 +39,17 @@ describe('extensionOrigin', () => { }); describe('isAllowedExtensionOrigin', () => { - it('trusts no extension until it has a published ID', () => { - expect(PANGULAR_EXTENSION_IDS).toEqual([]); + 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', () => { @@ -62,3 +74,20 @@ describe('isAllowedExtensionOrigin', () => { } }); }); + +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 b727761e..017bee26 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: { @@ -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 6db2a896..86a120fd 100644 --- a/packages/devtools/src/__tests__/hub.test.ts +++ b/packages/devtools/src/__tests__/hub.test.ts @@ -83,6 +83,7 @@ describe('Pangular Inspector hub', () => { }); }); +const PANGULAR_EXTENSION = 'chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk'; const EXTENSION = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; const OTHER_EXTENSION = 'chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba'; @@ -136,12 +137,13 @@ describe('Pangular Inspector hub behind a web router', () => { }); describe('Pangular Inspector hub origins', () => { - it('accepts loopback pages by default, and no Chrome extension without a known ID', () => { + 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', + PANGULAR_EXTENSION, ]) { expect(hubDefaultOrigins.isAllowed(origin)).toBe(true); } @@ -149,6 +151,7 @@ describe('Pangular Inspector hub origins', () => { 'https://evil.example', 'http://127.attacker.example', 'chrome-extension://', + `${PANGULAR_EXTENSION}/`, EXTENSION, OTHER_EXTENSION, 'moz-extension://abcdefghijklmnop', @@ -158,7 +161,8 @@ describe('Pangular Inspector hub origins', () => { } }); - it('refuses an unknown Chrome extension on the SSE stream by default', async () => { + 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); @@ -168,6 +172,7 @@ describe('Pangular Inspector hub origins', () => { 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); }); @@ -175,6 +180,8 @@ describe('Pangular Inspector hub origins', () => { 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(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 e1d1eab6..5cf4842d 100644 --- a/packages/devtools/src/__tests__/vite-auth.test.ts +++ b/packages/devtools/src/__tests__/vite-auth.test.ts @@ -147,17 +147,25 @@ 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 ours = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; - const { auth, warn, status } = plugin([ours]); + const selfBuilt = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; + const { auth, warn, status } = plugin([selfBuilt]); expect(auth).toBe(false); expect(warn).not.toHaveBeenCalled(); - expect(status(ours)).toBe('next'); + expect(status(selfBuilt)).toBe('next'); + expect(status('chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk')).toBe('next'); expect(status('chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba')).toBe(403); }); diff --git a/packages/devtools/src/__tests__/vite-upgrade-guard.test.ts b/packages/devtools/src/__tests__/vite-upgrade-guard.test.ts index 54d87e48..f9f763e6 100644 --- a/packages/devtools/src/__tests__/vite-upgrade-guard.test.ts +++ b/packages/devtools/src/__tests__/vite-upgrade-guard.test.ts @@ -9,6 +9,7 @@ import { isHubPath, } from '../vite.ts'; +const PANGULAR_EXTENSION = 'chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk'; const EXTENSION = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; const OTHER_EXTENSION = 'chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba'; @@ -74,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://', OTHER_EXTENSION]) { + 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(); @@ -83,13 +90,14 @@ describe('hubUpgradeListener', () => { 'http://localhost:5173', 'https://127.0.0.1:4200', 'http://[::1]:3000', + PANGULAR_EXTENSION, undefined, ]) { expect( upgrade(server, '127.0.0.1', '/__devframes/__ws', origin).destroy, ).not.toHaveBeenCalled(); } - expect(hub).toHaveBeenCalledTimes(4); + expect(hub).toHaveBeenCalledTimes(5); }); it('normalizes the path before deciding whether an upgrade targets the hub', () => { @@ -107,9 +115,11 @@ describe('hubUpgradeListener', () => { }); describe('hub origin policy', () => { - it('allows loopback http(s) pages, and rejects other sites and unknown extensions', () => { + 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(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); @@ -119,13 +129,15 @@ describe('hub origin policy', () => { expect(hubOriginRegistry.isAllowed(EXTENSION)).toBe(false); }); - it('accepts only the extension IDs listed in allowedOrigins', () => { + 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 index 5134b140..04795b8f 100644 --- a/packages/devtools/src/extension-origin.ts +++ b/packages/devtools/src/extension-origin.ts @@ -1,8 +1,12 @@ /** - * IDs of published Pangular Inspector extension builds, trusted without any - * `allowedOrigins` entry. Empty until the extension has a fixed ID. + * 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[] = []; +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}$/; diff --git a/packages/devtools/src/hub.ts b/packages/devtools/src/hub.ts index be4e2681..1a6afb1d 100644 --- a/packages/devtools/src/hub.ts +++ b/packages/devtools/src/hub.ts @@ -8,7 +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 { isAllowedExtensionOrigin } from './extension-origin.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' }; @@ -56,6 +56,14 @@ export const hubDefaultOrigins: WsOriginRegistry = { (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 { @@ -98,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/scripts/extension-zip.mjs b/scripts/extension-zip.mjs new file mode 100644 index 00000000..5ea160cd --- /dev/null +++ b/scripts/extension-zip.mjs @@ -0,0 +1,31 @@ +import { execFileSync } from 'node:child_process'; +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')); + 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}`); + 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 }); +} From 1ac3c32f746b7c64db79abe046d5fa0e374adb95 Mon Sep 17 00:00:00 2001 From: Kam Date: Wed, 7 Oct 2026 12:25:56 +0300 Subject: [PATCH 3/3] fix(extension): check the store key and word the 403 hint for both refusals extension:zip now stops when PANGULAR_EXTENSION_KEY doesn't match the manifest key, since a different key would give the store item another ID that the server refuses. The 403 hint no longer presents allowedOrigins as the fix for every refusal: the same 403 also means the request didn't come from this machine. --- apps/docs/src/content/contributing/chrome-extension.md | 2 +- extension/panel-bridge.js | 2 +- .../src/__tests__/extension-panel-bridge.test.ts | 2 +- scripts/extension-zip.mjs | 10 ++++++++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/docs/src/content/contributing/chrome-extension.md b/apps/docs/src/content/contributing/chrome-extension.md index b6cb56e1..e6bda6b9 100644 --- a/apps/docs/src/content/contributing/chrome-extension.md +++ b/apps/docs/src/content/contributing/chrome-extension.md @@ -89,7 +89,7 @@ For the first upload of a new store item, give the script the private key so the PANGULAR_EXTENSION_KEY=/path/to/pangular-inspector-extension-key.pem pnpm extension:zip ``` -The key goes into the zip as `key.pem`. Later updates don't need it. +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 diff --git a/extension/panel-bridge.js b/extension/panel-bridge.js index 48b293ad..2a9f9638 100644 --- a/extension/panel-bridge.js +++ b/extension/panel-bridge.js @@ -101,7 +101,7 @@ async function detectConnection() { const extension = chrome.runtime.getURL('').replace(/\/$/, ''); const hint = refused.status === 403 && extension !== PINNED_ORIGIN - ? ` To trust this extension, add ${extension} to allowedOrigins.` + ? ` 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}${hint} Tried:`, diff --git a/packages/devtools/src/__tests__/extension-panel-bridge.test.ts b/packages/devtools/src/__tests__/extension-panel-bridge.test.ts index 017bee26..04a948aa 100644 --- a/packages/devtools/src/__tests__/extension-panel-bridge.test.ts +++ b/packages/devtools/src/__tests__/extension-panel-bridge.test.ts @@ -267,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." To trust this extension, add chrome-extension://ext-id to allowedOrigins. 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)', diff --git a/scripts/extension-zip.mjs b/scripts/extension-zip.mjs index 5ea160cd..25c53570 100644 --- a/scripts/extension-zip.mjs +++ b/scripts/extension-zip.mjs @@ -1,4 +1,5 @@ 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'; @@ -15,10 +16,19 @@ try { }); 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 });