Only open http and https addresses externally - #121
Conversation
|
Findings: 0 (🔴 0 · 🟡 0 · 🔵 0) — no This PR replaces the direct Verified:
Style / process notesNone. |
The `url:open` handler passed whatever it received to `shell.openExternal`, which hands an address to whatever application the OS registered for its scheme. That is a wider action than "show this page in the browser": a `file:` address opens an arbitrary local path in its associated application — on Windows that can mean running it rather than viewing it — and any other registered scheme is reachable the same way. Every caller passes an http/https address today, so nothing misuses it. It matters as the second half of a chain: the renderer displays content the app does not author, and the URL it auto-opens on server start is parsed out of the Playground server's stdout. This guard is the step that keeps any future influence over that string from becoming an action on the contributor's machine. The check lives in its own module so both sides of it can be tested without an Electron process — `shell.openExternal` in the app, a recording stub in the tests. A refusal is logged rather than dropped, so a future caller that trips the guard shows up in the log file instead of just doing nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Checking one string and opening another is not a check. The URL parser strips tabs and newlines from anywhere in the input, including the middle of the scheme, so 'ht\ntp://example.com/x' validates as http: while `openExternal` would receive an address carrying a newline for the OS parser to resolve by its own rules. Forwarding `parsed.href` closes the gap between the two: the address that was checked is the address that gets opened. For every caller in the app the two forms are identical but for the trailing slash the parser adds to a bare origin. The rules file listed this handler as its known-open calibration example, which stops being true here. Replaced with what the fix teaches — the allow-list and the normalization are two halves, and the second is the one that is easy to miss in the next handler that takes a URL or a path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The refused address goes into the log verbatim, and electron-log passes newlines through unchanged, so an address carrying one could close its line and open another in the app's own timestamp-and-scope format. A log that can be made to describe events that never happened is worse than no log — and being the diagnosable trace for a caller that trips the guard is the whole reason the refusal is logged at all. Control characters are now escaped rather than dropped, so the line still says what the caller actually sent, and truncation runs afterwards, since escaping is what decides the final length. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
d3d56b3 to
aadd1ec
Compare
There was a problem hiding this comment.
Pull request overview
Restricts external URL opening to normalized HTTP/HTTPS addresses, closing issue #116.
Changes:
- Adds URL validation, normalization, and safe refusal logging.
- Routes
url:openthrough the new guard. - Adds comprehensive security regression tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/external-url.js |
Implements the URL security boundary. |
src/main.js |
Applies the guard to IPC requests. |
test/external-url.test.cjs |
Tests allowed, refused, malformed, and forged inputs. |
.github/instructions/code-review.instructions.md |
Updates review guidance for URL validation. |
Closes #129. The house pattern is to lift a handler's decision-making into a small module and test that module directly. Every test in `test/` talks to the module; none talks to the handler. So the connection between the two is untested: delete the `openExternalUrl` call from the `url:open` handler, call `shell.openExternal` in its place, and the whole suite stays green while the app loses the guard #121 added. For a module that formats a string that is a small gap. For one that exists to refuse something, the connection is the entire point. ## What changed **`test/ipc-wiring.test.cjs`** (new) — loads `src/main.js` in an ordinary `node --test` process with `require('electron')` replaced by a recording stub, captures the callbacks main.js registers with `ipcMain.handle`, and invokes them with the delegate module stubbed. It runs the real handler body, so it fails on a deleted call, a renamed export, or a bypass added beside it — not just on missing text. Nothing in the startup path runs: the stub's `app.whenReady()` returns a promise that never settles, so requiring the file registers handlers and does nothing else. No Electron process, and the suite stays a plain `node --test`. The harness lives in the test file rather than a helper, because `node --test` discovers every `.cjs` under `test/` and would report a helper as a file of its own — and the suite is deliberately run without a path (see the comment in `scripts/run-tests-electron.cjs`). **`src/main.js`** — the `electron-store` import moves from module load into `getStore()`. It has to: an ESM `import()` is invisible to the `Module._load` hook, so at require time it pulled in the real `electron` behind the stub's back and rejected. Deferring it also removes a startup unhandled rejection — a promise nobody was awaiting yet, at the one moment the app has no way to report it. Concurrent callers still share a single import, and a failure still surfaces to the handler that asked. No behaviour change: no handler can run before a window exists. ## The coverage guard The last test requires every channel main.js registers to be classified, each with a reason: - **wired** — asserted by a test above. - **no delegation** — electron-store reads and writes, a dialog, a path check. There is no module call to delete. - **unwired invariants** — `npm:kill`, `playground:start/stop`, `playground-web:start/stop`. These call no module; they hold the spawn and kill invariants inline instead (the child env built by hand rather than by `buildChildEnv`, `shell: false`, `detached` on POSIX, `child.kill()` where the npm paths use `killChildTree`). Deleting `detached:` from `playground:start` still leaves this suite green. Recorded rather than folded into "no delegation", because fixing it is a decision about main.js — see follow-ups. - **not reachable** — `site:status` and `wordpress:setup` do delegate, but behind a store read and a network clone respectively. `ipcMain.on` channels are recorded too, so a future one-way handler cannot arrive invisible to the guard. A new handler fails the suite until someone classifies it, and a renamed one fails it from the other side. That is what makes this a fix to the pattern rather than seven one-off tests. ## Testing 14 tests: `url:open` → `external-url` (both with a spy and end-to-end through the real module — `file:` refused and logged, `https:` opened as the normalized href), `git:worktree-dirty` / `git:discard-changes` / `git:update-trunk` / `sites:add` → `trunk-update`, the three patch channels → `ensureAutocrlf` and `git-update.normalizeEol` against a real throwaway repository, `npm:install` / `npm:run-script` → `npm-runner` (the environment `buildChildEnv` returned is the one that reaches `spawn`; a failing close consults `shouldRetryWithRelaxedEngines`, and a `true` actually starts a second attempt), and the `before-quit` sweep → `kill-tree`. Each assertion was mutation-checked — the acceptance criterion in the issue is "a handler that stops using its module fails the suite", so each mutation was applied alone and reverted: | mutation | result | | --- | --- | | `url:open` calls `shell.openExternal` directly | 2 tests fail | | `ensureAutocrlf` deleted from `sites:add` | 1 fails | | `ensureAutocrlf` deleted from the patch path | 2 fail | | `child.kill()` instead of `killChildTree` on quit | 1 fails | | child env hand-rolled instead of `buildChildEnv` | 2 fail | | `normalizeEol` dropped from both sides of the diff | 1 fails | | a new unclassified `ipcMain.handle` | guard fails | | a new `ipcMain.on` channel | guard fails | Suite: **172 passing, 0 failing**, on `node --test` and again on Electron's bundled Node via `npm run test:electron`. `npm run lint` clean across the repo. ## The second commit The first commit passed locally and broke `test/electron-node-version.test.cjs` on both platforms in CI. Worth writing down, because the trap is invisible on any machine that has already run the app. `node_modules/electron/index.js` resolves the binary path at module scope and spawns Electron's installer when it is missing. Requiring the package is therefore not inert on a cold checkout: it starts a download into the same `dist` directory that `electron-node-version.test.cjs` spawns the binary out of, and `node --test` runs files concurrently. That test got a half-written framework — `segment '__TEXT' load command content extends beyond end of file` on macOS, a second "Downloading Electron binary..." on Windows. Locally the binary is already there, the require is a file read, and nothing happens. Two paths reached it, both from the new file: - Stubs are built by merging over the real module, so stubbing `src/logging.js` means requiring it, and it requires `electron`. That require ran before the `Module._load` hook was installed. It now happens inside it, and the hook also covers `electron/*` subpaths and anything resolving into the package. - `sites:add` was allowed to run past its delegation into `getStore()`, whose `import('electron-store')` does `import {app} from 'electron'` through the ESM loader — where `Module._load` does not apply and no hook can help. The test now stops at the delegation, which is all it was ever asserting, the same way the patch-channel test does. The 14th test fails if the real package is loaded at all, since neither path is visible on a machine where the binary is present. Verified in both directions by removing `node_modules/electron/path.txt`: before the fix the suite prints "Downloading Electron binary..." and that test fails; after it, neither happens. No manual pass needed: nothing here ships in the app except the lazy store import, which no handler can observe. ## Review Ran the review in `.github/instructions/code-review.instructions.md`, judgement pass dispatched to a subagent with the diff and the standard. **1 `[fix here]` · 3 `[follow-up]`.** Fixed here: - 🔵 tests — the npm tests reach the real `runNpmWithEngineRetry`, which calls `ensureNodeShimDir()` and leaves `$TMPDIR/electron-node-shims-<pid>/` behind on every run, on both runtimes. It is module-local and cannot be stubbed; an `after()` hook now sweeps it. Also taken, though reported as follow-ups, because they were two lines each: - 🔵 tests — the guard only saw `ipcMain.handle`; `on` was a silent no-op in the stub. Now recorded and asserted. - 🟡 tests — "no delegation" was documented as "deleting a line from it could not disarm a guard", which was not true of the five spawn/kill handlers. Split into the `UNWIRED_INVARIANTS` list described above, with what each one holds inline. Left as follow-ups: - 🟡 tests — actually wiring those five handlers, either by giving them the modules the npm handlers use or by asserting their spawn options directly. It is a change to main.js, not to this test file, so it does not belong in this PR. - 🔵 security — `sites:delete` (`src/main.js:658`) does `fse.remove()` on a renderer-supplied path with no check that it is one of the registered sites. Pre-existing and unrelated to this change; filed as #133. Style notes, neither wrong today: `assert.equal(spawned.options.env, env)` asserts object identity, which is deliberate (a `deepEqual` would pass for a handler that rebuilt an identical object by hand) but would fail a future `env: {...buildChildEnv(), X: 1}`. And the `'child_process'` stub key now also registers under `'node:child_process'`, so switching to the prefixed spelling in main.js cannot silently unhook it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #133. Deleting a site removed whatever directory the request named. `sites:delete` took a `sitePath` straight from the renderer and called `fse.remove` on it — recursively, best-effort, error swallowed — without first checking that the path was one the app registered. The `sites` array in the store is the app's own record of what it created or adopted, so it is the boundary: a delete request naming anything outside it is refused and logged, and no directory is removed. This is the second half of the same chain as #121 (opening external addresses) — the window displays content the app does not author, and removing a directory is the least recoverable action reachable from it. ## What changed - **`src/site-registry.js`** (new) — an Electron-free module mirroring `external-url.js`: - `isRegisteredSite(sitePath, sites)` — pure exact-match check (same convention `sites:add`/`sites:delete` already use). - `describeRefusedSite(sitePath)` — safe log formatter: escapes control chars/newlines and truncates, so a crafted path can't forge a second entry in the log contributors attach to bug reports. - `deleteRegisteredSite(sitePath, { sites, forget, remove, onRefused })` — the handler body with injected effects; refuses (logs, no store mutation, no removal) when the path isn't registered. - **`src/main.js`** — `sites:delete` delegates to that wrapper. Refusals log via `logEvent('sites', 'refused to delete … — not a registered site')`, mirroring the `url:open` wiring. Removal stays best-effort. - **`test/site-registry.test.cjs`** (new) — 8 tests: registered/unregistered/junk paths, exact-match (parent and child of a registered site are rejected), truncation, and log-forging. ## Notes - The handler now returns `false` on refusal instead of always `true`. The renderer (`onDelete`) ignores the result and just refreshes, so the UI is unaffected — and this path is unreachable from the UI today. - Scoped to `sites:delete`, where the outcome is genuinely unrecoverable (a recursive `fse.remove`). The other site-path handlers use their path as a git worktree or a process cwd, with far smaller blast radius, so they are deliberately left as-is; the guard is a reusable module if one of them ever needs it. ## Done when - [x] A delete request naming a path the app does not have in its registry is refused, and the refusal shows up in the log. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Closes #116.
url:openpassed whatever it received toshell.openExternal, which hands an address to whatever application the OS has registered for its scheme — a wider action than "show this page in the browser". Afile:address opens an arbitrary local path in its associated application (on Windows that can mean running it rather than viewing it), and any other registered scheme is reachable the same way.Every caller passes an http/https address today, so nothing misuses it. It matters as the second half of a chain: the renderer displays content the app does not author, and the URL the app auto-opens on server start is parsed out of the Playground server's stdout. This guard is the step that keeps any future influence over that string from becoming an action on the contributor's machine.
What changed
src/external-url.js(new) —ALLOWED_URL_SCHEMES = ['http:', 'https:'], plusisAllowedExternalUrl()andopenExternalUrl(). The scheme is read off a parsednew URL()rather than the raw string, so casing and leading whitespace (FILE:,file:) normalize before the comparison instead of being a way around it; an address Node can't parse is refused rather than handed to the OS to interpret. What gets opened is the parser's ownhref, not the caller's string. That second half matters as much as the allow-list: the URL parser strips tabs and newlines from anywhere in the input, including the middle of the scheme, soht\ntp://example.com/xvalidates ashttp:— and forwarding the raw text would hand the OS an address nothing had checked. For every caller here the two forms differ only by the trailing slash the parser adds to a bare origin.src/main.js— the handler delegates to that module, passingshell.openExternaland anonRefusedthat logs through the existinglogEvent(scopeurl). A refusal is visible in the log file people attach to bug reports, not silent, so a future caller that trips the guard is diagnosable. The logged address is truncated, since by hypothesis it is the influenced input.The logic lives in its own module, per the repo convention (
src/bind-loopback.js), so both sides of the guard are testable without an Electron process.No user-facing behaviour changes: every existing caller passes http/https.
Testing
test/external-url.test.cjs— 11 tests, using a stub in place ofshell.openExternalso "did this reach the OS?" is an assertion rather than something the test takes on trust:ht\ntp://example.com/x) pins the normalization: it openshttp://example.com/x, not the string carrying the newline. This test fails against the first commit of this PR, which forwarded the raw input.file:///etc/passwdandfile:///C:/Windows/System32/cmd.exenever reach the stub. The Windows one is the case that matters most: the OS association for a.exeis "run it".javascript:,data:,mailto:, and OS-registered third-party schemes (ms-msdt:,vscode:) are refused too.'', whitespace,null,undefined, a number, an object, an array,'not a url') is refused rather than thrown.Suite: 158 passing, 0 failing, on
node --testand again on Electron's bundled Node vianpm run test:electron.npm run lintis clean across the repo.To verify by hand
Nothing in the UI can reach the refusal path by design, so the manual pass is about confirming the allowed side is unaffected. On a signed build from this branch:
window.api.openExternal('file:///etc/passwd'). It should resolvefalse, nothing should open, and the log file (Help → the log path) should contain aurlline readingrefused to open file:///etc/passwd — only http:, https: are allowed.Also updates
.github/instructions/code-review.instructions.md: it named this handler as its known-open calibration example, which stops being true here. Replaced with what the fix teaches, so the next review looks for both halves rather than re-reporting something already closed.Rebased on trunk after #122/#125/#127; the review standard's calibration example moved with it to
.github/instructions/.🤖 Generated with Claude Code