Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions apps/docs/src/content/contributing/chrome-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <a href="https://chrome.google.com/webstore/devconsole" target="_blank" rel="noopener noreferrer">Chrome Developer Dashboard</a>.
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.

<ngmd-alert severity="helpful">
Expand Down
24 changes: 22 additions & 2 deletions apps/docs/src/content/getting-started/chrome-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<id>` to it. The ID is on the extension card in `chrome://extensions`.
### Server origin

The panel sends requests from its own origin, `chrome-extension://<id>`. 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://<id>']});
```

```ts
// src/server.ts
const devtools = initPangularHub({
allowedOrigins: ['chrome-extension://<id>'],
});
```

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

Expand All @@ -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 <strong>Try again</strong>. See <a href="../security.md">Access and redaction</a>.
</ngmd-accordion-item>
<ngmd-accordion-item title="The panel says the server refused the request">
The server answered <code>401</code> or <code>403</code>. The Vite plugin refuses requests that do not come from your machine. Open the app on <code>localhost</code>, or see <a href="./vite.md#answers-only-your-machine">Answers only your machine</a>.
The server answered <code>401</code> or <code>403</code>. If you built the extension with another ID, the server refuses its origin until you add it to <code>allowedOrigins</code>. On <code>403</code>, the message names the origin to add. See <a href="#server-origin">Server origin</a>. The Vite plugin also refuses requests that do not come from your machine. Open the app on <code>localhost</code>, or see <a href="./vite.md#answers-only-your-machine">Answers only your machine</a>.
</ngmd-accordion-item>
<ngmd-accordion-item title="The panel shows another tab">
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.
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/src/content/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<id>` origin in `allowedOrigins`.

### Express hub

Pass the options next to the [access options](../security.md#express-hub) `auth`, `allowedOrigins` and `mcp`:
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/src/content/getting-started/express.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>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 `<base>__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).
Expand All @@ -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://<id>` 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
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/getting-started/vite.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<id>`. 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.

Expand Down
24 changes: 14 additions & 10 deletions apps/docs/src/content/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ The devtools read your running app and send what they find to a server on your m

<ngmd-card-grid columns="2">
<ngmd-card icon="zap" title="Vite plugin">
Loopback requests only. A request that sends an <code>Origin</code> must come from a loopback host, a Chrome extension, <code>allowedOrigins</code> or Vite's <code>server.allowedHosts</code>. Asks for a one-time code when a non-loopback host or origin is allowed.
Loopback requests only. A request that sends an <code>Origin</code> must come from a loopback host, the Pangular Inspector extension, an entry in <code>allowedOrigins</code> or Vite's <code>server.allowedHosts</code>. Asks for a one-time code when a non-loopback host or origin is allowed.
</ngmd-card>
<ngmd-card icon="layers" title="Express hub">
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.
</ngmd-card>
<ngmd-card icon="terminal" title="Standalone CLI">
Binds to <code>localhost</code> and asks for a one-time code by default.
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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://<id>`, with the ID from `chrome://extensions`.
A list keeps loopback origins and the Pangular Inspector extension. See [Chrome extension](#chrome-extension).

<ngmd-callout type="warning" title="Turning the checks off">
Pass <code>auth: false</code> 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. <code>allowedOrigins: false</code> turns the origin check off. Keep the check on for your own apps.
Expand All @@ -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://<id>` 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

Expand Down
1 change: 1 addition & 0 deletions extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion extension/panel-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading