diff --git a/CLAUDE.md b/CLAUDE.md index ae330c3..2d42484 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,13 +5,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project status Five packages are merged to main and working: -- `@gitmarks/core` (`packages/core/`) — schemas, GitHub Contents API client with optimistic concurrency, ULID/URL helpers (incl. opt-in tracking-param stripping), pure mutation helpers, example fixtures. 65 unit tests. -- `@gitmarks/extension-shared` (`packages/extension-shared/`) — canonical owner of the cross-browser extension code: popup, options, background, all of `src/lib/`, and the chrome/browser stub. 96 unit tests live here. Consumed by both browser shells via `workspace:*`. Uses `browser.*` via `webextension-polyfill`. +- `@gitmarks/core` (`packages/core/`) — schemas, GitHub Contents API client with optimistic concurrency, ULID/URL helpers (incl. opt-in tracking-param stripping), pure mutation helpers, example fixtures. 77 unit tests. +- `@gitmarks/extension-shared` (`packages/extension-shared/`) — canonical owner of the cross-browser extension code: popup, options, background, all of `src/lib/`, and the chrome/browser stub. 100 unit tests live here. Consumed by both browser shells via `workspace:*`. Uses `browser.*` via `webextension-polyfill`. - `@gitmarks/extension-chrome` (`packages/extension-chrome/`) — Chrome MV3 shell. Manifest + Vite/crxjs build + Playwright e2e (4 passing, 2 skipped — see issue history for the activeTab/Playwright limitation). Source files are thin entries that re-export from `extension-shared` via its `exports` map. - `@gitmarks/extension-firefox` (`packages/extension-firefox/`) — Firefox MV3 shell. Manifest + plain Vite build + manual smoke test (Playwright Firefox doesn't reliably drive WebExtensions). Targets Firefox 121+ for MV3 SW parity. Load via `about:debugging` → "Load Temporary Add-on". -- `@gitmarks/web` (`packages/web/`) — Vite + React + Tailwind SPA. List, search, tag management, bulk operations, trash, Netscape HTML export. Talks directly to GitHub via `@gitmarks/core`. Hash routing (`#/setup`, `#/`, `#/tags`, `#/trash`). 105 unit + component tests. +- `@gitmarks/web` (`packages/web/`) — Vite + React + Tailwind SPA. List, search, tag management, bulk operations, trash, Netscape HTML export. Talks directly to GitHub via `@gitmarks/core`. Hash routing (`#/setup`, `#/`, `#/tags`, `#/trash`). 109 unit + component tests. -Total: 272 unit + component tests across the monorepo, plus 6 Playwright e2e (4 passing, 2 skipped) in the Chrome shell. +Total: 286 unit + component tests across the monorepo, plus 6 Playwright e2e (4 passing, 2 skipped) in the Chrome shell. Pending packages (in dependency order): Safari. @@ -32,6 +32,10 @@ These are spec-level constraints; don't violate without an explicit discussion: - **IDs are ULIDs generated client-side.** Native browser node IDs are not stable across reinstalls — the extension maintains a `{ulid: chrome_node_id}` map in `chrome.storage.local`, rebuilt by URL match. - **Folder ↔ string path:** `Bookmarks Bar` ↔ `""` (root), `Other Bookmarks` ↔ `"_other"`, nested folders joined with `/`. Folder structure is derived from bookmarks, not stored separately. - **Loop suppression:** when applying a remote change to `chrome.bookmarks`, register the URL in an in-memory TTL map for ~2s so our own listener doesn't echo it back to GitHub. +- **URL safety:** Bookmark URLs are checked against an allowlist of safe schemes (`isSafeBookmarkUrl` in `@gitmarks/core`) at three points: (a) save time in the extension's `buildBookmark` factory, (b) render time in the web UI's `BookmarkRow`, and (c) the extension's `apply-remote` boundary that writes remote entries into the native bookmark tree. Unsafe schemes (`javascript:`, `data:`, etc.) are rejected/skipped. +- **Remote file validation:** `useGitmarksData` re-validates `bookmarks.json` and `tags.json` through Zod (`bookmarksFileSchema` / `tagsFileSchema`) after reading from GitHub. Malformed remote data surfaces as an error rather than rendering attacker-controlled fields. +- **Tag color guard:** `TagChip` regex-validates the color string before placing it into a CSS `style` object; malformed colors fall back to a default. +- **CSP:** Web UI's `index.html` and both extension manifests carry an explicit Content-Security-Policy restricting `connect-src` to `https://api.github.com`, disallowing inline scripts, framing, and form actions. ## Architecture by package @@ -59,7 +63,7 @@ Cross-browser source — owns all popup, options, background, and `src/lib/` mod - `folder-path.ts` — tree node ↔ `"Research/AI"` path conversion - `id-mapping.ts` — bidirectional `{ulid: chromeNodeId}` map - `suppression.ts` — in-memory URL + nodeId TTL maps (2s) to prevent loop-back - - `apply-remote.ts` — push a `BookmarksFile` state into `browser.bookmarks` + - `apply-remote.ts` — push a `BookmarksFile` state into `browser.bookmarks`; filters unsafe URL schemes (`javascript:`, `data:`, etc.) via `isSafeBookmarkUrl` from core - `reconcile.ts` — merge local tree and remote file by URL on cold start - `listeners.ts` — `browser.bookmarks.*` listeners with 500ms global debounce, batched flush - `background-core.ts` — dependency-injected `runMaybeReconcile` and `runPollRemoteOnce` (testable orchestration extracted from the SW entry) diff --git a/README.md b/README.md index 2b16ee7..aadab32 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ you control. **Status:** Chrome extension is functional end-to-end (save via toolbar button, two-way sync with the native bookmark tree, 5-min poll for remote changes, automatic conflict retry). Firefox MV3 add-on shipping the same -source as Chrome via a shared package. Web UI v1 (read-side: list, search, -tag management) deploys as a static SPA. Web UI v2 (bulk ops + trash + -export) and Safari are next in the roadmap. See `spec.md` for the full design. +source as Chrome via a shared package. Web UI (list, search, tag management, +bulk operations, trash, Netscape HTML export, sign out) deploys as a static +SPA. Safari is next in the roadmap. See `spec.md` for the full design. ## Features (Chrome, today) @@ -26,7 +26,7 @@ export) and Safari are next in the roadmap. See `spec.md` for the full design. on the next 5-minute poll - Concurrent edits from multiple devices reconcile automatically via GitHub's file SHA + optimistic retry-replay -- 272 automated unit + component tests + 6 Playwright e2e (against real Chromium) +- 286 automated unit + component tests + 6 Playwright e2e (against real Chromium) - Optional **tracking-param stripping** (utm_*, fbclid, gclid, etc.) at save time — opt-in via settings ## Packages @@ -34,10 +34,10 @@ export) and Safari are next in the roadmap. See `spec.md` for the full design. | Package | Role | |---|---| | `@gitmarks/core` | Shared TypeScript library: schemas (Zod), GitHub Contents API client with optimistic concurrency, ULID + URL helpers, pure mutation helpers | -| `@gitmarks/extension-shared` | Cross-browser extension source — popup, options, background, lib/ helpers. Consumed by both browser shells via `workspace:*`. 96 unit tests live here. | +| `@gitmarks/extension-shared` | Cross-browser extension source — popup, options, background, lib/ helpers. Consumed by both browser shells via `workspace:*`. 100 unit tests live here. | | `@gitmarks/extension-chrome` | Chrome MV3 shell. Manifest + Vite/crxjs build + Playwright e2e. Thin entry files import from `extension-shared`. | | `@gitmarks/extension-firefox` | Firefox MV3 shell. Manifest + plain Vite build. Same source as Chrome via `extension-shared`. Load via `about:debugging`. | -| `@gitmarks/web` | Static SPA — list, search, tag management. Vite + React + Tailwind. Talks directly to GitHub via `@gitmarks/core`. Deploys to GitHub Pages or Cloudflare Pages. | +| `@gitmarks/web` | Static SPA — list, search, tag management, bulk operations, trash, Netscape HTML export, sign out. Vite + React + Tailwind. Talks directly to GitHub via `@gitmarks/core`. Deploys to GitHub Pages or Cloudflare Pages. | ## Quick start (Chrome extension) @@ -72,6 +72,16 @@ the manual smoke test checklist, and architecture notes. - **No telemetry.** The extension only talks to `api.github.com`. That's enforced by the MV3 manifest's `host_permissions`. +### PAT lifecycle / revocation + +When you stop using gitmarks (uninstall the extension, clear browser data, or switch machines): + +1. **Revoke the PAT on github.com.** Settings → Developer settings → Personal access tokens → Fine-grained tokens → find the one named for your bookmarks repo → **Delete**. This is the only authoritative way to invalidate the credential. +2. **Web UI:** click **Sign out** in the header. This clears `localStorage` on your current machine. (It does NOT revoke the token on GitHub — see step 1.) +3. **Extension:** uninstalling the extension removes its `chrome.storage.local` entry on that machine. The token on GitHub remains valid until you revoke it. + +Treat the PAT like a saved password. If a machine is lost or compromised, revoke immediately on github.com. + ## Development ```bash diff --git a/docs/superpowers/plans/2026-05-26-gitmarks-hygiene-and-hardening.md b/docs/superpowers/plans/2026-05-26-gitmarks-hygiene-and-hardening.md new file mode 100644 index 0000000..3edd450 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-gitmarks-hygiene-and-hardening.md @@ -0,0 +1,756 @@ +# Hygiene + Security Follow-ups Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close four open follow-up issues in one pass — HTML deduplication across extension shells (#33), `chrome.*` → `browser.*` type-position migration (#34), test assertion namespace cleanup (#35), and the remaining defense-in-depth security follow-ups from the web UI v2 audit (#39). + +**Architecture:** Each issue is small and isolated; we land them as a single hardening branch with one commit per task. No new packages; no new dependencies. The work touches all three extension packages plus the web package. + +**Tech Stack:** Same as the existing monorepo — TypeScript 5.4, Vite 5, Vitest 2, `webextension-polyfill@0.12.0`. + +**Branch:** `feat/hygiene-and-hardening` + +--- + +## File Structure + +Files modified (no new packages): + +``` +packages/extension-chrome/ + package.json # MODIFY: copy-html script + run before vite build + scripts/copy-html.mjs # NEW + src/popup.html # DELETE (now copied at build time) + src/options.html # DELETE + .gitignore # MODIFY: ignore src/popup.html, src/options.html + manifest.config.ts # MODIFY: explicit content_security_policy.extension_pages + package.json # MODIFY: drop @types/chrome + tsconfig.json # MODIFY: drop chrome from types + +packages/extension-firefox/ + package.json # MODIFY: copy-html script + scripts/copy-html.mjs # NEW + src/popup.html # DELETE + src/options.html # DELETE + .gitignore # MODIFY + manifest.json # MODIFY: explicit content_security_policy.extension_pages + package.json # MODIFY: drop @types/chrome + tsconfig.json # MODIFY: drop chrome from types + +packages/extension-shared/ + src/lib/apply-remote.ts # MODIFY: type ref + skip unsafe URLs from remote + src/lib/listeners.ts # MODIFY: type refs + src/lib/reconcile.ts # MODIFY: type ref + test/setup.ts # MODIFY: type refs + jsdoc note + test/apply-remote.test.ts # MODIFY: chrome.* → browser.*; add unsafe-URL skip test + test/listeners.test.ts # MODIFY: chrome.* → browser.* + test/reconcile.test.ts # MODIFY: chrome.* → browser.* + package.json # MODIFY: drop @types/chrome + tsconfig.json # MODIFY: drop chrome from types + +packages/web/ + index.html # MODIFY: CSP meta tag + src/components/Layout.tsx # MODIFY: optional onSignOut + Sign out button + src/routes/{ListPage,TagsPage,TrashPage}.tsx # MODIFY: wire onSignOut + test/components.Layout.test.tsx # MODIFY: test onSignOut + +README.md # MODIFY: PAT lifecycle section +packages/extension-chrome/README.md # MODIFY: PAT revocation note in uninstall +packages/extension-firefox/README.md # MODIFY: PAT revocation note +CLAUDE.md # MODIFY: status updates, security posture summary +``` + +--- + +## Task 1: HTML deduplication (#33) + +Single source of truth: `packages/extension-shared/src/{popup,options}.html`. Each shell gets a `copy-html.mjs` script that copies the shared HTML into its own `src/` before Vite builds. The copies are gitignored. + +**Files:** +- Delete: `packages/extension-chrome/src/{popup,options}.html` +- Delete: `packages/extension-firefox/src/{popup,options}.html` +- Create: `packages/extension-chrome/scripts/copy-html.mjs` +- Create: `packages/extension-firefox/scripts/copy-html.mjs` +- Modify: `packages/extension-chrome/package.json` (prebuild hook + dev-time copy) +- Modify: `packages/extension-firefox/package.json` +- Modify: both `.gitignore` files + +- [ ] **Step 1: Create the copy script in `packages/extension-chrome/scripts/copy-html.mjs`** + +```javascript +import { copyFileSync, mkdirSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const shellRoot = resolve(here, ".."); +const sharedHtmlDir = resolve(shellRoot, "../extension-shared/src"); +const targetDir = resolve(shellRoot, "src"); + +if (!existsSync(sharedHtmlDir)) { + throw new Error(`shared html dir not found: ${sharedHtmlDir}`); +} +mkdirSync(targetDir, { recursive: true }); +for (const file of ["popup.html", "options.html"]) { + copyFileSync(resolve(sharedHtmlDir, file), resolve(targetDir, file)); +} +console.log("[chrome] copied popup.html + options.html from extension-shared"); +``` + +- [ ] **Step 2: Same script for Firefox** — `packages/extension-firefox/scripts/copy-html.mjs` (identical except the log prefix says `[firefox]`). + +- [ ] **Step 3: Wire the script via `prebuild` + `predev` hooks** + +In `packages/extension-chrome/package.json`, modify `scripts`: + +```json +"scripts": { + "predev": "node ./scripts/copy-html.mjs", + "dev": "vite", + "prebuild": "node ./scripts/copy-html.mjs", + "build": "vite build", + "preview": "vite preview", + "pretypecheck": "node ./scripts/copy-html.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "e2e": "node ./scripts/copy-html.mjs && playwright test" +} +``` + +For `packages/extension-firefox/package.json`, similar additions plus the existing manifest copy: + +```json +"scripts": { + "predev": "node ./scripts/copy-html.mjs", + "dev": "vite", + "prebuild": "node ./scripts/copy-html.mjs", + "build": "vite build && node ./scripts/copy-manifest.mjs", + "preview": "vite preview", + "pretypecheck": "node ./scripts/copy-html.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest" +} +``` + +Adapt to whatever each `package.json` currently contains — the goal is "copy-html runs before any command that reads `src/*.html`." + +- [ ] **Step 4: Delete the duplicate files** + +```bash +rm packages/extension-chrome/src/popup.html packages/extension-chrome/src/options.html +rm packages/extension-firefox/src/popup.html packages/extension-firefox/src/options.html +``` + +- [ ] **Step 5: Update `.gitignore` in each shell** + +Append to `packages/extension-chrome/.gitignore` and `packages/extension-firefox/.gitignore`: + +``` +src/popup.html +src/options.html +``` + +- [ ] **Step 6: Verify each build still works** + +```bash +pnpm --filter @gitmarks/core build +pnpm --filter @gitmarks/extension-chrome build +pnpm --filter @gitmarks/extension-firefox build +``` + +Both shells emit `dist/popup.html` + `dist/options.html` (or the crxjs-mangled equivalents). The hidden `src/popup.html` reappears after `prebuild` runs — that's fine; it's now a build artifact. + +Run the full pipeline: + +```bash +pnpm typecheck +pnpm test +``` + +All green. + +- [ ] **Step 7: Commit** + +```bash +git checkout -b feat/hygiene-and-hardening +git add packages/extension-chrome packages/extension-firefox +git commit -m "fix(ext): dedupe popup.html + options.html via copy-html prebuild script" +``` + +--- + +## Task 2: Type-position migration `chrome.*` → `browser.*` (#34) + +Five files in `extension-shared` still have `chrome.bookmarks.*` / `chrome.runtime.LastError` type annotations from before the `browser.*` value-position migration. Plus `test/setup.ts` references `chrome.bookmarks.BookmarkCreateArg` etc. + +**Strategy:** import the `Bookmarks` and `Runtime` namespaces from `webextension-polyfill` and use them in type position. After migration, drop `@types/chrome` from all three extension packages' devDeps and tsconfig `types` arrays. + +**Files:** +- Modify: `packages/extension-shared/src/lib/apply-remote.ts` +- Modify: `packages/extension-shared/src/lib/listeners.ts` +- Modify: `packages/extension-shared/src/lib/reconcile.ts` +- Modify: `packages/extension-shared/test/setup.ts` +- Modify: `packages/extension-shared/tsconfig.json` (drop `"chrome"` from types) +- Modify: `packages/extension-shared/package.json` (drop `@types/chrome`) +- Modify: `packages/extension-chrome/tsconfig.json` +- Modify: `packages/extension-chrome/package.json` +- Modify: `packages/extension-firefox/tsconfig.json` +- Modify: `packages/extension-firefox/package.json` + +- [ ] **Step 1: Migrate type-position refs in `apply-remote.ts`** + +Find: +```typescript +let current: chrome.bookmarks.BookmarkTreeNode | undefined; +``` + +Replace by adding to the imports at the top: +```typescript +import type { Bookmarks } from "webextension-polyfill"; +``` + +And changing the line to: +```typescript +let current: Bookmarks.BookmarkTreeNode | undefined; +``` + +- [ ] **Step 2: Migrate type-position refs in `listeners.ts`** + +Add the same `import type { Bookmarks } from "webextension-polyfill";` at the top. + +Replace: +- `chrome.bookmarks.BookmarkTreeNode` → `Bookmarks.BookmarkTreeNode` +- `chrome.bookmarks.BookmarkChangeInfo` → `Bookmarks.OnChangedChangeInfoType` +- `chrome.bookmarks.BookmarkMoveInfo` → `Bookmarks.OnMovedMoveInfoType` +- `chrome.bookmarks.BookmarkRemoveInfo` → `Bookmarks.OnRemovedRemoveInfoType` + +(The polyfill's type names differ from `@types/chrome`'s — verify the exact names against `node_modules/webextension-polyfill/index.d.ts` if the typecheck fails.) + +- [ ] **Step 3: Migrate type-position ref in `reconcile.ts`** + +Add `import type { Bookmarks } from "webextension-polyfill";` and replace `chrome.bookmarks.BookmarkTreeNode` → `Bookmarks.BookmarkTreeNode`. + +- [ ] **Step 4: Migrate `test/setup.ts`** + +The current setup.ts uses `chrome.bookmarks.BookmarkCreateArg`, `chrome.bookmarks.BookmarkTreeNode`, `chrome.runtime.LastError`. Add: + +```typescript +import type { Bookmarks, Runtime } from "webextension-polyfill"; +``` + +Replace: +- `chrome.bookmarks.BookmarkCreateArg` → `Bookmarks.CreateDetails` +- `chrome.bookmarks.BookmarkTreeNode` → `Bookmarks.BookmarkTreeNode` +- `chrome.runtime.LastError` → `Runtime.PropertyLastErrorType` (or whatever the polyfill exports — verify) + +The stub's runtime `lastError` field can be typed as `Runtime.PropertyLastErrorType | undefined` or simply `{ message: string } | undefined` if the polyfill doesn't expose a matching type. + +- [ ] **Step 5: Drop `@types/chrome` from devDeps + tsconfig** + +In `packages/extension-shared/package.json`, `packages/extension-chrome/package.json`, `packages/extension-firefox/package.json`: +- Remove `"@types/chrome": "^0.0.268"` from `devDependencies`. + +In each package's `tsconfig.json`: +- Remove `"chrome"` from the `compilerOptions.types` array. If `types` is now empty, drop the key entirely. + +- [ ] **Step 6: Verify** + +```bash +pnpm install # syncs the dropped dep +pnpm --filter @gitmarks/core build +pnpm typecheck +pnpm test +pnpm build +``` + +All green. `chrome.*` type-position refs should be gone from `extension-shared`. + +Sanity-check via grep: `grep -RIn "chrome\." packages/extension-shared/src` should return zero hits for type-position uses (it's OK if value uses still appear in `popup.ts` etc. via the polyfill — but they shouldn't, since FF-2 migrated those). + +- [ ] **Step 7: Commit** + +```bash +git add packages/extension-shared packages/extension-chrome packages/extension-firefox +git commit -m "refactor(ext-shared): migrate chrome.* type refs to webextension-polyfill namespace; drop @types/chrome" +``` + +--- + +## Task 3: Test assertion namespace cleanup (#35) + +14 assertions across 3 test files reference `chrome.bookmarks.create` / `chrome.runtime.openOptionsPage` etc. directly. They pass today because the test setup stubs both `globalThis.chrome` and `globalThis.browser` to the same object. Production calls `browser.*`; rewrite the assertions to match. + +**Files:** +- Modify: `packages/extension-shared/test/apply-remote.test.ts` +- Modify: `packages/extension-shared/test/listeners.test.ts` +- Modify: `packages/extension-shared/test/reconcile.test.ts` + +- [ ] **Step 1: Find and replace in each test file** + +For each of the three files, replace every `chrome.bookmarks.X` reference with `browser.bookmarks.X`. Run `grep -n "chrome\." packages/extension-shared/test/` to enumerate. Each `expect(chrome.bookmarks.create).toHaveBeenCalled...` becomes `expect(browser.bookmarks.create)...`. + +Use the Edit tool. Examples: + +```typescript +// before +expect(chrome.bookmarks.create).toHaveBeenCalledWith({...}); +// after +expect(browser.bookmarks.create).toHaveBeenCalledWith({...}); +``` + +(`browser` is already a globalThis stub from `test/setup.ts`.) + +`settings.test.ts` and `machine-id.test.ts` use `chrome.storage.local.*` in their assertions — migrate those too. + +- [ ] **Step 2: Verify** + +```bash +pnpm --filter @gitmarks/extension-shared test +``` + +96 tests should still pass — the underlying stub object is the same; we just changed the access path. + +Also run `grep -RIn "chrome\." packages/extension-shared/test` to confirm zero remaining hits (except in any intentional polyfill-shim test). + +- [ ] **Step 3: Commit** + +```bash +git add packages/extension-shared/test +git commit -m "test(ext-shared): assert against browser.* mock paths to match production calls" +``` + +--- + +## Task 4: CSP on web UI + extension manifests (#39 item 1+2) + +Defense-in-depth content security policies. + +**Files:** +- Modify: `packages/web/index.html` +- Modify: `packages/extension-chrome/manifest.config.ts` +- Modify: `packages/extension-firefox/manifest.json` + +- [ ] **Step 1: Add CSP meta tag to `packages/web/index.html`** + +Inside the `` block, before ``: + +```html +<meta + http-equiv="Content-Security-Policy" + content="default-src 'self'; connect-src https://api.github.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none';" +/> +``` + +Notes for the implementer: +- `connect-src https://api.github.com` — only allow the GitHub API host. Blocks any exfil to other hosts. +- `style-src 'self' 'unsafe-inline'` — Tailwind needs inline styles; tighter is a follow-up. +- `script-src 'self'` — disallows inline scripts. +- `img-src 'self' data:` — favicons / embedded SVG ok. +- `frame-ancestors 'none'` — clickjacking defense. +- `object-src 'none'` — defends against plugin-based attacks. + +After this, run `pnpm --filter @gitmarks/web build && pnpm --filter @gitmarks/web preview`. Open the preview URL; confirm no CSP violations in the browser console. If a violation appears (e.g., the Vite-built CSS uses an inline `<style>` element that needs `style-src 'self' 'unsafe-inline'` — already covered), document and proceed. + +- [ ] **Step 2: Add explicit CSP to Chrome manifest** + +`packages/extension-chrome/manifest.config.ts` — extend the `defineManifest` object: + +```typescript +export default defineManifest({ + // …existing keys… + content_security_policy: { + extension_pages: "script-src 'self'; object-src 'self'; connect-src https://api.github.com", + }, +}); +``` + +(MV3's default is already `script-src 'self'`; we're making it explicit and adding the `connect-src` allowlist so popups/options pages cannot reach hosts other than the GitHub API.) + +- [ ] **Step 3: Add explicit CSP to Firefox manifest** + +`packages/extension-firefox/manifest.json` — add: + +```json +"content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'; connect-src https://api.github.com" +} +``` + +- [ ] **Step 4: Verify** + +```bash +pnpm typecheck +pnpm test +pnpm build +``` + +All green. Both extension builds emit manifests containing the CSP. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/index.html packages/extension-chrome/manifest.config.ts packages/extension-firefox/manifest.json +git commit -m "feat(security): explicit CSP on web index.html and both extension manifests" +``` + +--- + +## Task 5: Sign out / clear local data button in web UI (#39 item 3) + +`clearSettings()` exists but is unreachable from the UI. Add a button in `Layout` that calls it, then navigates to `/setup`. + +**Files:** +- Modify: `packages/web/src/components/Layout.tsx` +- Modify: `packages/web/src/routes/{ListPage,TagsPage,TrashPage}.tsx` +- Modify: `packages/web/test/components.Layout.test.tsx` + +- [ ] **Step 1: Add failing test to `packages/web/test/components.Layout.test.tsx`** + +Append inside the existing `describe("Layout", …)`: + +```typescript + it("renders a Sign out button when onSignOut is provided and invokes it", async () => { + const onSignOut = vi.fn(); + const user = userEvent.setup(); + render( + <MemoryRouter> + <Layout + status={{ kind: "ok", message: "synced" }} + onRefresh={() => {}} + onSignOut={onSignOut} + refreshing={false} + > + <div /> + </Layout> + </MemoryRouter>, + ); + await user.click(screen.getByRole("button", { name: /sign out/i })); + expect(onSignOut).toHaveBeenCalled(); + }); +``` + +Run: `pnpm --filter @gitmarks/web test test/components.Layout.test.tsx`. Expected: FAIL. + +- [ ] **Step 2: Add the `onSignOut` prop to `Layout`** + +In `packages/web/src/components/Layout.tsx`: + +```typescript +interface Props { + children: ReactNode; + status: LayoutStatus; + onRefresh: () => void; + onExport?: () => void; + onSignOut?: () => void; + refreshing: boolean; +} +``` + +In the header's right-side div, add the Sign out button after Export (and before Sync): + +```typescript +{onSignOut !== undefined && ( + <button + type="button" + onClick={onSignOut} + className="px-3 py-1 rounded border border-magenta text-magenta hover:bg-magenta hover:text-ink" + > + Sign out + </button> +)} +``` + +(Magenta = same "danger" colorway used for Move-to-trash. Sign-out is irreversible without re-entering the PAT.) + +- [ ] **Step 3: Wire `onSignOut` in each page** + +In `packages/web/src/routes/ListPage.tsx`, `TagsPage.tsx`, and `TrashPage.tsx`, add: + +```typescript +import { useNavigate } from "react-router-dom"; +import { clearSettings } from "../lib/settings.js"; + +// inside the component: +const navigate = useNavigate(); +function onSignOut() { + clearSettings(); + navigate("/setup"); +} + +// pass onSignOut to Layout: +<Layout status={status} onRefresh={onRefresh} onExport={onExport} onSignOut={onSignOut} refreshing={refreshing}> +``` + +- [ ] **Step 4: Verify** + +```bash +pnpm --filter @gitmarks/web test +pnpm --filter @gitmarks/web typecheck +``` + +All previous + 1 new = pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/components/Layout.tsx packages/web/src/routes/ListPage.tsx packages/web/src/routes/TagsPage.tsx packages/web/src/routes/TrashPage.tsx packages/web/test/components.Layout.test.tsx +git commit -m "feat(web): sign-out button that clears localStorage and returns to setup" +``` + +--- + +## Task 6: PAT-lifecycle README notes (#39 item 4) + +Document that extension uninstall does NOT clear `chrome.storage.local` (it does in practice — but the storage was already isolated; the PAT in github.com remains valid until the user revokes it). Steer users to revoke on github.com. + +**Files:** +- Modify: `README.md` +- Modify: `packages/extension-chrome/README.md` +- Modify: `packages/extension-firefox/README.md` + +- [ ] **Step 1: Add a section to root `README.md`** + +Find the "## Your data, your PAT" section. After the existing bullets, append: + +```markdown +### PAT lifecycle / revocation + +When you stop using gitmarks (uninstall the extension, clear browser data, or switch machines): + +1. **Revoke the PAT on github.com.** Settings → Developer settings → Personal access tokens → Fine-grained tokens → find the one named for your bookmarks repo → **Delete**. This is the only authoritative way to invalidate the credential. +2. **Web UI:** click **Sign out** in the header. This clears `localStorage` on your current machine. (It does NOT revoke the token on GitHub — see step 1.) +3. **Extension:** uninstalling the extension removes its `chrome.storage.local` entry on that machine. The token on GitHub remains valid until you revoke it. + +Treat the PAT like a saved password. If a machine is lost or compromised, revoke immediately on github.com. +``` + +- [ ] **Step 2: Add a brief revoke-on-uninstall note to each extension README** + +In both `packages/extension-chrome/README.md` and `packages/extension-firefox/README.md`, find the "First-run setup" section. After it, add (if not already present): + +```markdown +## Uninstall + +Removing the extension clears `chrome.storage.local` (or the Firefox equivalent) for this browser profile. **It does NOT revoke your GitHub PAT** — that token remains valid on github.com until you delete it manually: + +1. github.com → Settings → Developer settings → Personal access tokens → Fine-grained tokens. +2. Find the token you created for gitmarks → **Delete**. +``` + +- [ ] **Step 3: Verify + commit** + +```bash +git add README.md packages/extension-chrome/README.md packages/extension-firefox/README.md +git commit -m "docs: document PAT lifecycle and revocation on uninstall" +``` + +--- + +## Task 7: URL safety at apply-remote boundary (#39 item 5) + +The extension's `apply-remote.ts` writes remote bookmarks to `chrome.bookmarks` via `browser.bookmarks.create({ url, … })`. Chrome accepts any URL string, including `javascript:`. If a remote `bookmarks.json` contains a malicious entry, the user's native bookmarks tree now has a clickable script payload. + +**Strategy:** Use `isSafeBookmarkUrl` from `@gitmarks/core` at the apply-remote boundary. Skip + log unsafe entries; the local tree never sees them. + +**Files:** +- Modify: `packages/extension-shared/src/lib/apply-remote.ts` +- Modify: `packages/extension-shared/test/apply-remote.test.ts` + +- [ ] **Step 1: Add a failing test to `packages/extension-shared/test/apply-remote.test.ts`** + +Append to the existing `describe(...)`: + +```typescript + it("skips bookmarks with unsafe URL schemes (javascript:, data:)", async () => { + const remote: BookmarksFile = { + version: 1, + updated_at: "2026-05-25T00:00:00Z", + bookmarks: [ + mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CA", url: "javascript:alert(1)" }), + mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CB", url: "https://example.com/safe" }), + mkBookmark({ id: "01HXYZ8K7M9P3RQ2V5W6Z8B0CC", url: "data:text/html,<script>1</script>" }), + ], + }; + const idMap = makeIdMap(); + await applyRemoteChanges(remote, idMap, "bar-1", "other-1"); + const calls = (browser.bookmarks.create as Mock).mock.calls; + const urls = calls.map((c) => c[0].url); + expect(urls).toContain("https://example.com/safe"); + expect(urls).not.toContain("javascript:alert(1)"); + expect(urls).not.toContain(expect.stringMatching(/^data:/)); + }); +``` + +Adapt to whatever helpers the existing test file uses (`mkBookmark`, `makeIdMap`, etc.). + +- [ ] **Step 2: Add the URL safety filter to `apply-remote.ts`** + +At the top of the file: + +```typescript +import { isSafeBookmarkUrl } from "@gitmarks/core"; +``` + +Inside `applyRemoteChanges`, in the loop over `remote.bookmarks`, after extracting `bm`: + +```typescript +for (const bm of remote.bookmarks) { + if (bm.deleted_at == null && !isSafeBookmarkUrl(bm.url)) { + console.warn("[gitmarks] skipping remote bookmark with unsafe URL scheme", { + ulid: bm.id, url: bm.url, + }); + continue; + } + // …existing logic… +} +``` + +(Place the guard AFTER the `deleted_at` check so tombstones still propagate — removing a previously-safe local bookmark whose REMOTE entry has been corrupted to `javascript:` should still cleanly delete the local node.) + +Wait — re-reading: if `deleted_at != null` AND url is unsafe, we still want to handle the soft delete (remove the local node if it exists). The simpler check: only validate URL when we're about to CREATE or UPDATE. The existing flow: + +```typescript +if (bm.deleted_at != null) { /* delete branch */ continue; } +if (existingNode != null) { /* update branch */ continue; } +/* create branch */ +``` + +Add the URL safety check before EACH of the create/update branches that actually touch `browser.bookmarks` with a URL. Or, simpler: add a single guard right after the delete-branch handles its case: + +```typescript +for (const bm of remote.bookmarks) { + if (bm.deleted_at != null) { + // …existing delete logic — runs even if URL is unsafe… + continue; + } + if (!isSafeBookmarkUrl(bm.url)) { + console.warn("[gitmarks] skipping remote bookmark with unsafe URL scheme", { ulid: bm.id, url: bm.url }); + continue; + } + // …existing create/update logic… +} +``` + +- [ ] **Step 3: Also guard `applyRemoteEdit` against unsafe remote URLs** + +In `applyRemoteEdit(nodeId, remoteUrl, remoteTitle)`, before the `changes` object is built: + +```typescript +if (!isSafeBookmarkUrl(remoteUrl)) { + console.warn("[gitmarks] skipping remote edit with unsafe URL scheme", { nodeId, url: remoteUrl }); + return; +} +``` + +(This prevents an existing safe bookmark from being silently rewritten to a `javascript:` URL on a remote update.) + +- [ ] **Step 4: Verify** + +```bash +pnpm --filter @gitmarks/core build +pnpm --filter @gitmarks/extension-shared test +pnpm --filter @gitmarks/extension-shared typecheck +``` + +All previous + 1 new test pass. + +- [ ] **Step 5: Commit** + +```bash +git add packages/extension-shared/src/lib/apply-remote.ts packages/extension-shared/test/apply-remote.test.ts +git commit -m "fix(security): filter unsafe URL schemes at apply-remote boundary" +``` + +--- + +## Task 8: Docs + roadmap update + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `README.md` (optional — only if test counts changed substantially) + +- [ ] **Step 1: Update `CLAUDE.md` "Project status"** + +Refresh the test totals to the new numbers. Get them from `pnpm test 2>&1 | tail -10`. Update the per-package counts and the overall total. + +- [ ] **Step 2: Note the security posture in CLAUDE.md** + +Find the "Load-bearing invariants" section. After the existing bullets, add: + +```markdown +- **URL safety:** Bookmark URLs are checked against an allowlist of safe schemes (`isSafeBookmarkUrl` in `@gitmarks/core`) at (a) save time in the extension's `buildBookmark` factory, (b) render time in the web UI's `BookmarkRow`, and (c) the extension's `apply-remote` boundary that writes remote entries into the native bookmark tree. Unsafe schemes (`javascript:`, `data:`, etc.) are rejected/skipped. +- **Remote file validation:** `useGitmarksData` re-validates `bookmarks.json` and `tags.json` through Zod (`bookmarksFileSchema` / `tagsFileSchema`) after reading from GitHub. Malformed remote data surfaces as an error rather than rendering attacker-controlled fields. +- **Tag color guard:** `TagChip` regex-validates the color string before placing it into a CSS `style` object; malformed colors fall back to a default. +``` + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md README.md +git commit -m "docs: refresh test counts and document security posture" +``` + +--- + +## Final Verification + +- [ ] **Run the full pipeline at repo root** + +```bash +pnpm install +pnpm --filter @gitmarks/core build +pnpm typecheck +pnpm test +pnpm build +``` + +All five green. + +- [ ] **Open the PR** + +```bash +git push -u origin feat/hygiene-and-hardening +gh pr create --title "fix: hygiene + security follow-ups (closes #33, #34, #35, #39)" --body "$(cat <<'EOF' +## Summary +- **#33** — HTML duplication: single source in `@gitmarks/extension-shared`; shells copy at build time. +- **#34** — `chrome.*` type-position refs migrated to `webextension-polyfill`'s namespace; `@types/chrome` dropped from all three extension packages. +- **#35** — Test assertions rewritten to target `browser.bookmarks.*` instead of `chrome.bookmarks.*`. +- **#39** — Defense-in-depth security: CSP meta on web index.html + extension manifests; sign-out button in web UI; PAT-lifecycle docs; unsafe URLs filtered at `apply-remote` boundary. + +Closes #33, #34, #35, #39. + +## Test plan +- [x] Full monorepo green (typecheck + test + build). +- [ ] Manual smoke test: load both extensions; web UI's CSP doesn't break dev or built preview; Sign out clears settings. +- [ ] Manual smoke test: edit a remote `bookmarks.json` to add a `javascript:` URL → the extension's poll skips it (warns in console) and never creates the bookmark in the native tree. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Wait for CI green, then merge with a merge commit. + +--- + +## Cross-Reference + +| Issue | Task | +|---|---| +| #33 HTML dedup | Task 1 | +| #34 chrome.* type refs | Task 2 | +| #35 test assertion namespace | Task 3 | +| #39 item 1 (CSP web) | Task 4 | +| #39 item 2 (CSP manifests) | Task 4 | +| #39 item 3 (sign-out button) | Task 5 | +| #39 item 4 (PAT lifecycle README) | Task 6 | +| #39 item 5 (apply-remote URL filter) | Task 7 | + +## Notes for the implementer + +- Tasks 1, 2, 3 are interlocking: Task 1 changes how HTML lands in shells, Task 2 changes types, Task 3 changes tests. Do them in order. +- Task 7 depends on Task 2 being done first (it imports `isSafeBookmarkUrl` which already lives in core; Task 2 just needs to not have broken anything when dropping `@types/chrome`). +- The Vite build of the Firefox shell expects `src/popup.html` and `src/options.html` to exist at the moment Vite starts. The `prebuild` script handles `pnpm build`; for `pnpm dev` to work, `predev` runs first. +- If the polyfill type names differ from the spec text (e.g., `Bookmarks.OnChangedChangeInfoType` doesn't exist), grep `node_modules/webextension-polyfill/index.d.ts` for the right name. Update the plan inline if you find a mismatch — it's a documentation-only correction. diff --git a/packages/extension-chrome/.gitignore b/packages/extension-chrome/.gitignore new file mode 100644 index 0000000..aecafef --- /dev/null +++ b/packages/extension-chrome/.gitignore @@ -0,0 +1,2 @@ +src/popup.html +src/options.html diff --git a/packages/extension-chrome/README.md b/packages/extension-chrome/README.md index 099df79..1642ecd 100644 --- a/packages/extension-chrome/README.md +++ b/packages/extension-chrome/README.md @@ -41,6 +41,15 @@ are deferred). Pin it for easy access. first save)". 5. Click **Save**. +## Uninstall + +Removing the extension clears `chrome.storage.local` for this browser profile. **It does NOT revoke your GitHub PAT** — that token remains valid on github.com until you delete it manually: + +1. github.com → Settings → Developer settings → Personal access tokens → Fine-grained tokens. +2. Find the token you created for gitmarks → **Delete**. + +This is the authoritative way to invalidate the credential. Re-installing the extension after revoking will prompt for a fresh PAT. + ## Manual smoke test This is the **authoritative verification** that production wiring works. diff --git a/packages/extension-chrome/manifest.config.ts b/packages/extension-chrome/manifest.config.ts index 583c8b0..274119f 100644 --- a/packages/extension-chrome/manifest.config.ts +++ b/packages/extension-chrome/manifest.config.ts @@ -16,4 +16,7 @@ export default defineManifest({ service_worker: "src/background.ts", type: "module", }, + content_security_policy: { + extension_pages: "script-src 'self'; object-src 'self'; connect-src 'self' https://api.github.com", + }, }); diff --git a/packages/extension-chrome/package.json b/packages/extension-chrome/package.json index c881e20..3e02b2a 100644 --- a/packages/extension-chrome/package.json +++ b/packages/extension-chrome/package.json @@ -4,12 +4,16 @@ "private": true, "type": "module", "scripts": { - "build": "vite build", + "predev": "node ./scripts/copy-html.mjs", "dev": "vite build --watch --mode development", + "prebuild": "node ./scripts/copy-html.mjs", + "build": "vite build", + "pretypecheck": "node ./scripts/copy-html.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", + "pree2e": "node ./scripts/copy-html.mjs && vite build", "e2e": "playwright test", - "e2e:headed": "playwright test --headed", - "pretest:e2e": "vite build" + "pree2e:headed": "node ./scripts/copy-html.mjs && vite build", + "e2e:headed": "playwright test --headed" }, "dependencies": { "@gitmarks/core": "workspace:*", @@ -19,7 +23,6 @@ "devDependencies": { "@crxjs/vite-plugin": "^2.4.0", "@playwright/test": "^1.48.0", - "@types/chrome": "^0.0.268", "vite": "^5.4.0" } } diff --git a/packages/extension-chrome/scripts/copy-html.mjs b/packages/extension-chrome/scripts/copy-html.mjs new file mode 100644 index 0000000..49cf701 --- /dev/null +++ b/packages/extension-chrome/scripts/copy-html.mjs @@ -0,0 +1,23 @@ +import { copyFileSync, mkdirSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const shellRoot = resolve(here, ".."); +const sharedHtmlDir = resolve(shellRoot, "../extension-shared/src"); +const targetDir = resolve(shellRoot, "src"); + +if (!existsSync(sharedHtmlDir)) { + throw new Error(`shared html dir not found: ${sharedHtmlDir}`); +} +mkdirSync(targetDir, { recursive: true }); +for (const file of ["popup.html", "options.html"]) { + const src = resolve(sharedHtmlDir, file); + if (!existsSync(src)) { + throw new Error( + `shared html file not found: ${src} — expected to live in @gitmarks/extension-shared/src/`, + ); + } + copyFileSync(src, resolve(targetDir, file)); +} +console.log("[chrome] copied popup.html + options.html from extension-shared"); diff --git a/packages/extension-chrome/src/options.html b/packages/extension-chrome/src/options.html deleted file mode 100644 index 16309e7..0000000 --- a/packages/extension-chrome/src/options.html +++ /dev/null @@ -1,64 +0,0 @@ -<!doctype html> -<html lang="en"> - <head> - <meta charset="utf-8" /> - <title>gitmarks — settings - - - -

gitmarks settings

- - - - - - - - - - - -
- - -
- -

- - - - diff --git a/packages/extension-chrome/src/popup.html b/packages/extension-chrome/src/popup.html deleted file mode 100644 index 69a0b72..0000000 --- a/packages/extension-chrome/src/popup.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - gitmarks - - - -
loading…
- - - diff --git a/packages/extension-chrome/tsconfig.json b/packages/extension-chrome/tsconfig.json index 5fab6db..66d69ac 100644 --- a/packages/extension-chrome/tsconfig.json +++ b/packages/extension-chrome/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["chrome", "vite/client"], + "types": ["vite/client"], "rootDir": "./", "outDir": "./dist-tsc", "noEmit": true diff --git a/packages/extension-firefox/.gitignore b/packages/extension-firefox/.gitignore new file mode 100644 index 0000000..aecafef --- /dev/null +++ b/packages/extension-firefox/.gitignore @@ -0,0 +1,2 @@ +src/popup.html +src/options.html diff --git a/packages/extension-firefox/README.md b/packages/extension-firefox/README.md index 8689e3f..c73ca97 100644 --- a/packages/extension-firefox/README.md +++ b/packages/extension-firefox/README.md @@ -27,6 +27,15 @@ Identical to Chrome — see `packages/extension-chrome/README.md` "First-run setup". The popup, options page, PAT validation, and the optional **Strip tracking parameters** toggle all behave the same. +## Uninstall + +Removing the add-on clears its extension-local storage for this Firefox profile. **It does NOT revoke your GitHub PAT** — that token remains valid on github.com until you delete it manually: + +1. github.com → Settings → Developer settings → Personal access tokens → Fine-grained tokens. +2. Find the token you created for gitmarks → **Delete**. + +This is the authoritative way to invalidate the credential. + ## Manual smoke test The unit test suite (`pnpm --filter @gitmarks/extension-shared test`, diff --git a/packages/extension-firefox/manifest.json b/packages/extension-firefox/manifest.json index 7276473..e06a92b 100644 --- a/packages/extension-firefox/manifest.json +++ b/packages/extension-firefox/manifest.json @@ -17,6 +17,9 @@ "service_worker": "background.js", "type": "module" }, + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://api.github.com" + }, "browser_specific_settings": { "gecko": { "id": "gitmarks@paperhurts.dev", diff --git a/packages/extension-firefox/package.json b/packages/extension-firefox/package.json index 0334741..0efeb0b 100644 --- a/packages/extension-firefox/package.json +++ b/packages/extension-firefox/package.json @@ -4,7 +4,11 @@ "private": true, "type": "module", "scripts": { + "predev": "node ./scripts/copy-html.mjs", + "dev": "vite", + "prebuild": "node ./scripts/copy-html.mjs", "build": "vite build && node ./scripts/copy-manifest.mjs", + "pretypecheck": "node ./scripts/copy-html.mjs", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { @@ -12,7 +16,6 @@ "@gitmarks/extension-shared": "workspace:*" }, "devDependencies": { - "@types/chrome": "^0.0.268", "@types/node": "^22.0.0", "@types/webextension-polyfill": "^0.12.0", "vite": "^5.4.0" diff --git a/packages/extension-firefox/scripts/copy-html.mjs b/packages/extension-firefox/scripts/copy-html.mjs new file mode 100644 index 0000000..b049a2f --- /dev/null +++ b/packages/extension-firefox/scripts/copy-html.mjs @@ -0,0 +1,23 @@ +import { copyFileSync, mkdirSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const shellRoot = resolve(here, ".."); +const sharedHtmlDir = resolve(shellRoot, "../extension-shared/src"); +const targetDir = resolve(shellRoot, "src"); + +if (!existsSync(sharedHtmlDir)) { + throw new Error(`shared html dir not found: ${sharedHtmlDir}`); +} +mkdirSync(targetDir, { recursive: true }); +for (const file of ["popup.html", "options.html"]) { + const src = resolve(sharedHtmlDir, file); + if (!existsSync(src)) { + throw new Error( + `shared html file not found: ${src} — expected to live in @gitmarks/extension-shared/src/`, + ); + } + copyFileSync(src, resolve(targetDir, file)); +} +console.log("[firefox] copied popup.html + options.html from extension-shared"); diff --git a/packages/extension-firefox/src/options.html b/packages/extension-firefox/src/options.html deleted file mode 100644 index 16309e7..0000000 --- a/packages/extension-firefox/src/options.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - gitmarks — settings - - - -

gitmarks settings

- - - - - - - - - - - -
- - -
- -

- - - - diff --git a/packages/extension-firefox/src/popup.html b/packages/extension-firefox/src/popup.html deleted file mode 100644 index 69a0b72..0000000 --- a/packages/extension-firefox/src/popup.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - gitmarks - - - -
loading…
- - - diff --git a/packages/extension-firefox/tsconfig.json b/packages/extension-firefox/tsconfig.json index f665ba0..fa958a4 100644 --- a/packages/extension-firefox/tsconfig.json +++ b/packages/extension-firefox/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["chrome", "webextension-polyfill", "node"], + "types": ["webextension-polyfill", "node"], "rootDir": "./", "outDir": "./dist-tsc", "noEmit": true diff --git a/packages/extension-shared/package.json b/packages/extension-shared/package.json index bf7eb15..b174883 100644 --- a/packages/extension-shared/package.json +++ b/packages/extension-shared/package.json @@ -20,7 +20,6 @@ "zod": "^3.23.0" }, "devDependencies": { - "@types/chrome": "^0.0.268", "@types/webextension-polyfill": "^0.12.0", "jsdom": "^25.0.0", "vitest": "^2.0.0" diff --git a/packages/extension-shared/src/lib/apply-remote.ts b/packages/extension-shared/src/lib/apply-remote.ts index 05d1a47..73c0d39 100644 --- a/packages/extension-shared/src/lib/apply-remote.ts +++ b/packages/extension-shared/src/lib/apply-remote.ts @@ -1,5 +1,6 @@ import browser from "webextension-polyfill"; -import type { BookmarksFile } from "@gitmarks/core"; +import type { Bookmarks } from "webextension-polyfill"; +import { type BookmarksFile, isSafeBookmarkUrl } from "@gitmarks/core"; import { type IdMap, asUlid, asNodeId } from "./id-mapping.js"; import { splitFolderPath } from "./folder-path.js"; import { suppress, suppressNode } from "./suppression.js"; @@ -16,7 +17,7 @@ export async function applyRemoteChanges( if (bm.deleted_at != null) { if (existingNode != null) { - suppress(bm.url); + if (isSafeBookmarkUrl(bm.url)) suppress(bm.url); try { await browser.bookmarks.remove(existingNode); idMap.removeByUlid(asUlid(bm.id)); @@ -38,6 +39,13 @@ export async function applyRemoteChanges( continue; } + if (!isSafeBookmarkUrl(bm.url)) { + console.warn("[gitmarks] skipping remote bookmark with unsafe URL scheme", { + ulid: bm.id, url: bm.url, + }); + continue; + } + if (existingNode != null) { // Remote bookmark already in the local tree — propagate title/url // changes so users on Device A see edits from Device B within the @@ -72,7 +80,7 @@ async function applyRemoteEdit( remoteUrl: string, remoteTitle: string, ): Promise { - let current: chrome.bookmarks.BookmarkTreeNode | undefined; + let current: Bookmarks.BookmarkTreeNode | undefined; try { const found = await browser.bookmarks.get(nodeId); current = found[0]; diff --git a/packages/extension-shared/src/lib/listeners.ts b/packages/extension-shared/src/lib/listeners.ts index 44c9d1a..8dd38b1 100644 --- a/packages/extension-shared/src/lib/listeners.ts +++ b/packages/extension-shared/src/lib/listeners.ts @@ -1,4 +1,5 @@ import browser from "webextension-polyfill"; +import type { Bookmarks } from "webextension-polyfill"; import type { Bookmark, BookmarksFile, @@ -6,6 +7,7 @@ import type { } from "@gitmarks/core"; import { addBookmark, + isSafeBookmarkUrl, newUlid, normalizeUrl, softDeleteBookmark, @@ -104,7 +106,7 @@ async function runFlush(): Promise { } } -function onCreated(_id: string, node: chrome.bookmarks.BookmarkTreeNode): void { +function onCreated(_id: string, node: Bookmarks.BookmarkTreeNode): void { if (node.url == null || node.url.length === 0) return; pending.push({ kind: "create", @@ -115,9 +117,13 @@ function onCreated(_id: string, node: chrome.bookmarks.BookmarkTreeNode): void { schedule(); } -function onChanged(id: string, changeInfo: chrome.bookmarks.BookmarkChangeInfo): void { +function onChanged(id: string, changeInfo: Bookmarks.OnChangedChangeInfoType): void { + // Type note: the polyfill types `title: string` (required) on this payload, + // but Chrome's runtime omits it on URL-only changes. The undefined checks + // below are load-bearing despite the type — don't "clean up" the dead branch + // or rename sync silently breaks. const url = changeInfo.url; - const title = changeInfo.title; + const title = changeInfo.title as string | undefined; if (url === undefined && title === undefined) return; if (url !== undefined && title !== undefined) { pending.push({ kind: "update", nodeId: id, url, title }); @@ -129,11 +135,11 @@ function onChanged(id: string, changeInfo: chrome.bookmarks.BookmarkChangeInfo): schedule(); } -function onMoved(_id: string, _moveInfo: chrome.bookmarks.BookmarkMoveInfo): void { +function onMoved(_id: string, _moveInfo: Bookmarks.OnMovedMoveInfoType): void { // Folder moves are intentionally not pushed from the listener; the periodic reconcile catches folder drift. } -function onRemoved(id: string, removeInfo: chrome.bookmarks.BookmarkRemoveInfo): void { +function onRemoved(id: string, removeInfo: Bookmarks.OnRemovedRemoveInfoType): void { // Only sync bookmarks (URL-bearing nodes), not folders. if (removeInfo.node.url == null || removeInfo.node.url.length === 0) return; pending.push({ kind: "remove", nodeId: id, url: removeInfo.node.url }); @@ -246,6 +252,10 @@ function applyBatch( let file = initial; for (const event of batch) { if (event.kind === "create") { + if (!isSafeBookmarkUrl(event.url)) { + console.warn("[gitmarks] skipping create event with unsafe URL scheme", { url: event.url }); + continue; + } // Skip if already mapped — a previous batch already created the remote entry; // treat duplicate create events as no-ops. const id = createUlids.get(event.nodeId); @@ -275,6 +285,10 @@ function applyBatch( if (existing == null) continue; const patch: Partial> = {}; if ("url" in event && event.url !== undefined) { + if (!isSafeBookmarkUrl(event.url)) { + console.warn("[gitmarks] skipping update event with unsafe URL scheme", { url: event.url }); + continue; + } const normalized = normalizeUrl(event.url, { stripTrackingParams }); if (normalized !== existing.url) patch.url = normalized; } diff --git a/packages/extension-shared/src/lib/reconcile.ts b/packages/extension-shared/src/lib/reconcile.ts index 34f8185..dfe4b22 100644 --- a/packages/extension-shared/src/lib/reconcile.ts +++ b/packages/extension-shared/src/lib/reconcile.ts @@ -1,4 +1,5 @@ import browser from "webextension-polyfill"; +import type { Bookmarks } from "webextension-polyfill"; import type { BookmarksFile, Bookmark, @@ -6,6 +7,7 @@ import type { } from "@gitmarks/core"; import { GitHubNotFoundError, + isSafeBookmarkUrl, newUlid, normalizeUrl, addBookmark, @@ -43,6 +45,10 @@ export async function reconcile( const remoteByUrl = new Map(); for (const b of remote.bookmarks) { if (b.deleted_at != null) continue; + if (!isSafeBookmarkUrl(b.url)) { + console.warn("[gitmarks] reconcile: skipping remote bookmark with unsafe URL scheme", { ulid: b.id, url: b.url }); + continue; + } remoteByUrl.set(b.url, b); } @@ -58,6 +64,10 @@ export async function reconcile( const localOnly: LocalEntry[] = []; for (const [url, local] of localByUrl) { if (!remoteByUrl.has(url)) { + if (!isSafeBookmarkUrl(url)) { + console.warn("[gitmarks] reconcile: skipping local bookmark with unsafe URL scheme", { url }); + continue; + } localOnly.push(local); } } @@ -121,7 +131,7 @@ async function collectLocalBookmarks( } function walk( - node: chrome.bookmarks.BookmarkTreeNode, + node: Bookmarks.BookmarkTreeNode, out: Map, ): void { if (node.url != null && node.url.length > 0) { diff --git a/packages/extension-shared/test/apply-remote.test.ts b/packages/extension-shared/test/apply-remote.test.ts index 9036b96..aa23dbd 100644 --- a/packages/extension-shared/test/apply-remote.test.ts +++ b/packages/extension-shared/test/apply-remote.test.ts @@ -36,7 +36,7 @@ describe("applyRemoteChanges", () => { const bm = bookmark({ id: "u1", url: "https://example.com/new" }); const idMap = await IdMap.load(); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.create).toHaveBeenCalledWith({ + expect(browser.bookmarks.create).toHaveBeenCalledWith({ parentId: BAR, title: "Example", url: "https://example.com/new", @@ -54,13 +54,13 @@ describe("applyRemoteChanges", () => { idMap.set(asUlid("u1"), asNodeId("node-1")); // Current local node has the old title - (chrome.bookmarks.get as any).mockResolvedValueOnce([ + (browser.bookmarks.get as any).mockResolvedValueOnce([ { id: "node-1", parentId: BAR, title: "Old title", url: "https://example.com/" }, ]); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.update).toHaveBeenCalledWith("node-1", { + expect(browser.bookmarks.update).toHaveBeenCalledWith("node-1", { title: "New title from another device", }); // The URL is unchanged, but we still suppress to avoid an onChanged echo @@ -76,13 +76,13 @@ describe("applyRemoteChanges", () => { const idMap = await IdMap.load(); idMap.set(asUlid("u1"), asNodeId("node-1")); - (chrome.bookmarks.get as any).mockResolvedValueOnce([ + (browser.bookmarks.get as any).mockResolvedValueOnce([ { id: "node-1", parentId: BAR, title: "Same title", url: "https://example.com/old-path" }, ]); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.update).toHaveBeenCalledWith("node-1", { + expect(browser.bookmarks.update).toHaveBeenCalledWith("node-1", { url: "https://example.com/new-path", }); // Suppress BOTH the new URL (carried in the onChanged echo) AND the old @@ -91,27 +91,27 @@ describe("applyRemoteChanges", () => { expect(isSuppressed("https://example.com/old-path")).toBe(true); }); - it("silently skips a mapped-but-locally-deleted node (chrome.bookmarks.get throws 'not found')", async () => { + it("silently skips a mapped-but-locally-deleted node (browser.bookmarks.get throws 'not found')", async () => { const bm = bookmark({ id: "u1", url: "https://example.com/", title: "Doesn't matter" }); const idMap = await IdMap.load(); idMap.set(asUlid("u1"), asNodeId("node-gone")); - (chrome.bookmarks.get as any).mockRejectedValueOnce( + (browser.bookmarks.get as any).mockRejectedValueOnce( new Error("Can't find bookmark for id."), ); // Should not throw; should not invoke update await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.update).not.toHaveBeenCalled(); + expect(browser.bookmarks.update).not.toHaveBeenCalled(); }); - it("rethrows non-'not found' errors from chrome.bookmarks.get", async () => { + it("rethrows non-'not found' errors from browser.bookmarks.get", async () => { const bm = bookmark({ id: "u1", url: "https://example.com/", title: "Doesn't matter" }); const idMap = await IdMap.load(); idMap.set(asUlid("u1"), asNodeId("node-1")); - (chrome.bookmarks.get as any).mockRejectedValueOnce( + (browser.bookmarks.get as any).mockRejectedValueOnce( new Error("Extension context invalidated."), ); @@ -129,13 +129,13 @@ describe("applyRemoteChanges", () => { const idMap = await IdMap.load(); idMap.set(asUlid("u1"), asNodeId("node-1")); - (chrome.bookmarks.get as any).mockResolvedValueOnce([ + (browser.bookmarks.get as any).mockResolvedValueOnce([ { id: "node-1", parentId: BAR, title: "Same", url: "https://example.com/" }, ]); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.update).not.toHaveBeenCalled(); + expect(browser.bookmarks.update).not.toHaveBeenCalled(); }); it("does not create a bookmark already mapped", async () => { @@ -143,7 +143,7 @@ describe("applyRemoteChanges", () => { const idMap = await IdMap.load(); idMap.set(asUlid("u1"), asNodeId("node-1")); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.create).not.toHaveBeenCalled(); + expect(browser.bookmarks.create).not.toHaveBeenCalled(); }); it("removes a chrome node for a tombstoned remote bookmark", async () => { @@ -155,7 +155,7 @@ describe("applyRemoteChanges", () => { const idMap = await IdMap.load(); idMap.set(asUlid("u1"), asNodeId("node-1")); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.remove).toHaveBeenCalledWith("node-1"); + expect(browser.bookmarks.remove).toHaveBeenCalledWith("node-1"); expect(isSuppressed("https://example.com/")).toBe(true); }); @@ -163,7 +163,7 @@ describe("applyRemoteChanges", () => { const bm = bookmark({ id: "u1", url: "https://example.com/o", folder: "_other" }); const idMap = await IdMap.load(); await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); - expect(chrome.bookmarks.create).toHaveBeenCalledWith({ + expect(browser.bookmarks.create).toHaveBeenCalledWith({ parentId: OTHER, title: "Example", url: "https://example.com/o", @@ -176,10 +176,10 @@ describe("applyRemoteChanges", () => { // First getSubTree call (under BAR): no existing "Research" folder // Second getSubTree call (under the new Research folder): no existing "AI" - (chrome.bookmarks.getSubTree as any) + (browser.bookmarks.getSubTree as any) .mockResolvedValueOnce([{ id: BAR, children: [] }]) .mockResolvedValueOnce([{ id: "research-id", children: [] }]); - (chrome.bookmarks.create as any) + (browser.bookmarks.create as any) .mockResolvedValueOnce({ id: "research-id", title: "Research" }) // folder 1 .mockResolvedValueOnce({ id: "ai-id", title: "AI" }) // folder 2 .mockResolvedValueOnce({ id: "bm-node", url: bm.url, title: bm.title }); // bookmark @@ -187,19 +187,19 @@ describe("applyRemoteChanges", () => { await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); // Verify the bookmark itself was created under the AI folder - const createCalls = (chrome.bookmarks.create as any).mock.calls; + const createCalls = (browser.bookmarks.create as any).mock.calls; const bmCreate = createCalls.find((c: any) => c[0].url === "https://example.com/nested"); expect(bmCreate).toBeDefined(); expect(bmCreate[0].parentId).toBe("ai-id"); }); - it("saves the id map even when a later chrome.bookmarks.create throws", async () => { + it("saves the id map even when a later browser.bookmarks.create throws", async () => { const bm1 = bookmark({ id: "u1", url: "https://example.com/ok" }); const bm2 = bookmark({ id: "u2", url: "https://example.com/fail" }); const idMap = await IdMap.load(); // First create succeeds, second throws. - (chrome.bookmarks.create as any) + (browser.bookmarks.create as any) .mockResolvedValueOnce({ id: "node-1", url: bm1.url, title: bm1.title }) .mockRejectedValueOnce(new Error("boom")); @@ -217,10 +217,10 @@ describe("applyRemoteChanges", () => { const idMap = await IdMap.load(); // Existing "Reading" folder under BAR - (chrome.bookmarks.getSubTree as any).mockResolvedValueOnce([ + (browser.bookmarks.getSubTree as any).mockResolvedValueOnce([ { id: BAR, children: [{ id: "reading-id", title: "Reading" }] }, ]); - (chrome.bookmarks.create as any).mockResolvedValueOnce({ + (browser.bookmarks.create as any).mockResolvedValueOnce({ id: "bm-node", url: bm.url, title: bm.title, @@ -229,8 +229,38 @@ describe("applyRemoteChanges", () => { await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); // Only one create call (the bookmark itself) — the folder was reused - expect((chrome.bookmarks.create as any).mock.calls.length).toBe(1); - const bmCreate = (chrome.bookmarks.create as any).mock.calls[0]; + expect((browser.bookmarks.create as any).mock.calls.length).toBe(1); + const bmCreate = (browser.bookmarks.create as any).mock.calls[0]; expect(bmCreate[0].parentId).toBe("reading-id"); }); + + it("skips remote bookmarks with unsafe URL schemes (javascript:, data:)", async () => { + const idMap = await IdMap.load(); + const bms = [ + bookmark({ id: "u1", url: "javascript:alert(1)", title: "Evil JS" }), + bookmark({ id: "u2", url: "https://example.com/safe", title: "Safe" }), + bookmark({ id: "u3", url: "data:text/html,", title: "Evil Data" }), + ]; + await applyRemoteChanges(file(bms), idMap, BAR, OTHER); + const calls = (browser.bookmarks.create as unknown as { mock: { calls: Array<[{ url?: string }]> } }).mock.calls; + const urls = calls.map((c) => c[0].url ?? ""); + expect(urls).toContain("https://example.com/safe"); + expect(urls).not.toContain("javascript:alert(1)"); + expect(urls.some((u) => u.startsWith("data:"))).toBe(false); + }); + + it("outer URL guard short-circuits before reaching the edit branch for unsafe URL schemes", async () => { + // The outer guard at apply-remote.ts fires before applyRemoteEdit is + // reached, so browser.bookmarks.get (used inside applyRemoteEdit) is never + // called. This test makes that short-circuit explicit. + const idMap = await IdMap.load(); + // Pre-map u1 so applyRemoteChanges would normally hit the edit branch. + idMap.set(asUlid("u1"), asNodeId("node-1")); + const bm = bookmark({ id: "u1", url: "javascript:alert(2)", title: "Evil edit" }); + await applyRemoteChanges(file([bm]), idMap, BAR, OTHER); + expect(browser.bookmarks.update).not.toHaveBeenCalled(); + // Confirm the outer guard fired before entering applyRemoteEdit: + // browser.bookmarks.get would have been called if applyRemoteEdit had run. + expect(browser.bookmarks.get).not.toHaveBeenCalled(); + }); }); diff --git a/packages/extension-shared/test/globals.d.ts b/packages/extension-shared/test/globals.d.ts new file mode 100644 index 0000000..79add59 --- /dev/null +++ b/packages/extension-shared/test/globals.d.ts @@ -0,0 +1,25 @@ +/** + * Declares the `browser` global used by test stubs, typed via webextension-polyfill. + * test/setup.ts installs this via vi.stubGlobal("browser", chromeStub). + * + * IMPORTANT: this type is narrowed to the runtime API shape that chromeStub + * actually implements (the lowercase `Static`-typed runtime constants). + * Using `typeof Browser` would also expose the polyfill's capitalized + * sub-namespace re-exports (e.g. `browser.Bookmarks`) — those are types only; + * accessing them at runtime via the test stub crashes with "Cannot read + * properties of undefined". + */ +import type Browser from "webextension-polyfill"; + +interface TestBrowserApi { + bookmarks: Browser.Bookmarks.Static; + storage: Browser.Storage.Static; + runtime: Browser.Runtime.Static; + alarms: Browser.Alarms.Static; + tabs: Browser.Tabs.Static; +} + +declare global { + // eslint-disable-next-line no-var + var browser: TestBrowserApi; +} diff --git a/packages/extension-shared/test/listeners.test.ts b/packages/extension-shared/test/listeners.test.ts index ba7ce91..ca266c8 100644 --- a/packages/extension-shared/test/listeners.test.ts +++ b/packages/extension-shared/test/listeners.test.ts @@ -32,10 +32,10 @@ describe("listeners", () => { getBarOtherIds: async () => ({ bar: BAR, other: OTHER }), getMachineId: async () => machineId, }); - expect(chrome.bookmarks.onCreated.addListener).toHaveBeenCalledTimes(1); - expect(chrome.bookmarks.onChanged.addListener).toHaveBeenCalledTimes(1); - expect(chrome.bookmarks.onMoved.addListener).toHaveBeenCalledTimes(1); - expect(chrome.bookmarks.onRemoved.addListener).toHaveBeenCalledTimes(1); + expect(browser.bookmarks.onCreated.addListener).toHaveBeenCalledTimes(1); + expect(browser.bookmarks.onChanged.addListener).toHaveBeenCalledTimes(1); + expect(browser.bookmarks.onMoved.addListener).toHaveBeenCalledTimes(1); + expect(browser.bookmarks.onRemoved.addListener).toHaveBeenCalledTimes(1); }); it("flush pushes a pending create through GitHubClient.update", async () => { @@ -53,7 +53,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; createListener("node-new", { id: "node-new", parentId: BAR, @@ -85,7 +85,7 @@ describe("listeners", () => { suppress("https://suppressed.example/"); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; createListener("node-x", { id: "node-x", parentId: BAR, @@ -109,7 +109,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; for (let i = 0; i < 5; i++) { createListener(`node-${i}`, { id: `node-${i}`, @@ -150,7 +150,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; createListener("node-new", { id: "node-new", parentId: BAR, @@ -205,7 +205,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const changeListener = (chrome.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; + const changeListener = (browser.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; changeListener("node-existing", { title: "New title" }); await flushPending(); @@ -238,8 +238,8 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; - const changeListener = (chrome.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const changeListener = (browser.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; createListener("node-1", { id: "node-1", @@ -257,7 +257,7 @@ describe("listeners", () => { }); it("onChanged with no URL is suppressed when nodeId is in the node-suppression registry (title-only echo from apply-remote)", async () => { - // Issue #18 finding A: apply-remote's chrome.bookmarks.update({title}) + // Issue #18 finding A: apply-remote's browser.bookmarks.update({title}) // fires onChanged with changeInfo.url === undefined. URL-suppression // doesn't catch the echo. NodeId-suppression does. const update = vi.fn(); @@ -275,7 +275,7 @@ describe("listeners", () => { const { suppressNode } = await import("../src/lib/suppression.js"); suppressNode("node-1"); - const changeListener = (chrome.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; + const changeListener = (browser.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; changeListener("node-1", { title: "new" }); await flushPending(); @@ -294,7 +294,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const removeListener = (chrome.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; + const removeListener = (browser.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; removeListener("never-mapped-node", { parentId: BAR, index: 0, @@ -326,7 +326,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const changeListener = (chrome.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; + const changeListener = (browser.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; changeListener("never-mapped-node", { title: "Whatever" }); await flushPending(); @@ -370,7 +370,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const removeListener = (chrome.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; + const removeListener = (browser.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; removeListener("node-doomed", { parentId: BAR, index: 0, @@ -403,7 +403,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const removeListener = (chrome.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; + const removeListener = (browser.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; removeListener("node-x", { parentId: BAR, index: 0, @@ -431,7 +431,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const removeListener = (chrome.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; + const removeListener = (browser.bookmarks.onRemoved.addListener as any).mock.calls[0]![0]; removeListener("folder-node", { parentId: BAR, index: 0, @@ -443,6 +443,95 @@ describe("listeners", () => { expect(update).not.toHaveBeenCalled(); }); + it("applyBatch skips create events with unsafe URL scheme", async () => { + let captured: BookmarksFile | null = null; + const update = vi.fn(async (_p: string, mutate: any) => { + captured = mutate({ version: 1, updated_at: "x", bookmarks: [] }); + return { data: captured, sha: "s", etag: "" }; + }); + const client = fakeClient({ update }); + + registerListeners({ + getClient: async () => client, + getIdMap: async () => IdMap.load(), + getBarOtherIds: async () => ({ bar: BAR, other: OTHER }), + getMachineId: async () => machineId, + }); + + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + // Fire two creates: one unsafe, one safe. + createListener("node-unsafe", { + id: "node-unsafe", + parentId: BAR, + title: "Evil", + url: "javascript:alert(1)", + }); + createListener("node-safe", { + id: "node-safe", + parentId: BAR, + title: "Safe", + url: "https://safe.example/", + }); + + await flushPending(); + + // client.update must have been called (the safe bookmark survives) + expect(update).toHaveBeenCalledTimes(1); + expect(captured).not.toBeNull(); + const urls = captured!.bookmarks.map((b: any) => b.url); + expect(urls).toContain("https://safe.example/"); + expect(urls).not.toContain("javascript:alert(1)"); + }); + + it("applyBatch skips update events that set an unsafe URL", async () => { + let captured: BookmarksFile | null = null; + const safeBookmark = { + id: "existing-ulid", + url: "https://example.com/", + title: "Original", + folder: "", + tags: [], + added_at: "y", + updated_at: "y", + added_from: "chrome@other", + deleted_at: null, + notes: null, + }; + const update = vi.fn(async (_p: string, mutate: any) => { + const initial: BookmarksFile = { version: 1, updated_at: "x", bookmarks: [safeBookmark] }; + captured = mutate(initial); + return { data: captured, sha: "s", etag: "" }; + }); + const client = fakeClient({ update }); + const idMap = await IdMap.load(); + idMap.set(asUlid("existing-ulid"), asNodeId("node-existing")); + + registerListeners({ + getClient: async () => client, + getIdMap: async () => idMap, + getBarOtherIds: async () => ({ bar: BAR, other: OTHER }), + getMachineId: async () => machineId, + }); + + const changeListener = (browser.bookmarks.onChanged.addListener as any).mock.calls[0]![0]; + // User edits the bookmark URL to an unsafe value. + changeListener("node-existing", { url: "javascript:evil()" }); + + await flushPending(); + + // The update was skipped — client.update should not have been called because + // the only surviving event (the unsafe URL update) was dropped inside applyBatch. + // Either the event is filtered entirely (no call) OR the URL is absent from the result. + if (update.mock.calls.length > 0) { + expect(captured).not.toBeNull(); + const bm = captured!.bookmarks[0]!; + expect(bm.url).not.toBe("javascript:evil()"); + expect(bm.url).toBe("https://example.com/"); + } else { + expect(update).not.toHaveBeenCalled(); + } + }); + it("applies exponential backoff after a flush failure", async () => { const update = vi.fn(); // Make the first flush fail @@ -458,7 +547,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; createListener("node-1", { id: "node-1", parentId: BAR, @@ -481,7 +570,7 @@ describe("listeners", () => { it("clears gitmarks:lastError after a successful flush", async () => { // Seed an error - await chrome.storage.local.set({ + await browser.storage.local.set({ "gitmarks:lastError": { when: 1, message: "old", source: "flush" }, }); @@ -499,7 +588,7 @@ describe("listeners", () => { getMachineId: async () => machineId, }); - const createListener = (chrome.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; + const createListener = (browser.bookmarks.onCreated.addListener as any).mock.calls[0]![0]; createListener("node-1", { id: "node-1", parentId: BAR, @@ -510,7 +599,7 @@ describe("listeners", () => { // Advance past the debounce window to trigger runFlush (which clears the error key on success) await vi.advanceTimersByTimeAsync(600); - const stored = await chrome.storage.local.get("gitmarks:lastError"); + const stored = await browser.storage.local.get("gitmarks:lastError"); expect(stored["gitmarks:lastError"]).toBeUndefined(); }); }); diff --git a/packages/extension-shared/test/machine-id.test.ts b/packages/extension-shared/test/machine-id.test.ts index 0d32848..f987c03 100644 --- a/packages/extension-shared/test/machine-id.test.ts +++ b/packages/extension-shared/test/machine-id.test.ts @@ -13,9 +13,9 @@ describe("machine-id", () => { expect(a).toBe(b); }); - it("persists the id in chrome.storage.local under 'gitmarks:machineId'", async () => { + it("persists the id in browser.storage.local under 'gitmarks:machineId'", async () => { const id = await getMachineId(); - const stored = await chrome.storage.local.get("gitmarks:machineId"); + const stored = await browser.storage.local.get("gitmarks:machineId"); expect(stored["gitmarks:machineId"]).toBe(id); }); }); diff --git a/packages/extension-shared/test/reconcile.test.ts b/packages/extension-shared/test/reconcile.test.ts index 0b088c4..d6330fe 100644 --- a/packages/extension-shared/test/reconcile.test.ts +++ b/packages/extension-shared/test/reconcile.test.ts @@ -47,7 +47,7 @@ describe("reconcile", () => { const read = vi.fn(async () => ({ data: remote, sha: "s0", etag: "" })); const client = fakeClient({ read, update }); - (chrome.bookmarks.getTree as any).mockResolvedValueOnce([ + (browser.bookmarks.getTree as any).mockResolvedValueOnce([ { id: "root", children: [ { id: BAR, title: "Bookmarks Bar", children: [] }, { id: OTHER, title: "Other Bookmarks", children: [] }, @@ -57,7 +57,7 @@ describe("reconcile", () => { const idMap = await IdMap.load(); await reconcile(client, idMap, BAR, OTHER, machineId, nowIso); - expect(chrome.bookmarks.create).toHaveBeenCalledWith({ + expect(browser.bookmarks.create).toHaveBeenCalledWith({ parentId: BAR, title: "Example", url: "https://remote.example/", @@ -78,7 +78,7 @@ describe("reconcile", () => { }); const client = fakeClient({ read, update }); - (chrome.bookmarks.getTree as any).mockResolvedValueOnce([ + (browser.bookmarks.getTree as any).mockResolvedValueOnce([ { id: "root", children: [ { id: BAR, title: "Bookmarks Bar", children: [ { id: "node-1", parentId: BAR, title: "Local", url: "https://local.example/" }, @@ -107,7 +107,7 @@ describe("reconcile", () => { const update = vi.fn(); const client = fakeClient({ read, update }); - (chrome.bookmarks.getTree as any).mockResolvedValueOnce([ + (browser.bookmarks.getTree as any).mockResolvedValueOnce([ { id: "root", children: [ { id: BAR, title: "Bookmarks Bar", children: [ { id: "node-existing", parentId: BAR, title: "Shared", url: "https://shared.example/" }, @@ -119,11 +119,77 @@ describe("reconcile", () => { const idMap = await IdMap.load(); await reconcile(client, idMap, BAR, OTHER, machineId, nowIso); - expect(chrome.bookmarks.create).not.toHaveBeenCalled(); + expect(browser.bookmarks.create).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled(); expect(idMap.nodeForUlid(asUlid("u-existing"))).toBe("node-existing"); }); + it("does not upload local bookmarks with unsafe URL schemes", async () => { + const remote: BookmarksFile = { + version: 1, + updated_at: nowIso, + bookmarks: [], + }; + let written: BookmarksFile | null = null; + const read = vi.fn(async () => ({ data: remote, sha: "s0", etag: "" })); + const update = vi.fn(async (_p: string, mutate: any) => { + written = mutate(remote); + return { data: written, sha: "s1", etag: "" }; + }); + const client = fakeClient({ read, update }); + + (browser.bookmarks.getTree as any).mockResolvedValueOnce([ + { id: "root", children: [ + { id: BAR, title: "Bookmarks Bar", children: [ + { id: "node-safe", parentId: BAR, title: "Safe", url: "https://safe.example/" }, + { id: "node-evil", parentId: BAR, title: "Evil", url: "javascript:evil()" }, + ] }, + { id: OTHER, title: "Other Bookmarks", children: [] }, + ] }, + ]); + + const idMap = await IdMap.load(); + await reconcile(client, idMap, BAR, OTHER, machineId, nowIso); + + // The safe bookmark is uploaded; the unsafe one is silently dropped. + expect(written).not.toBeNull(); + const urls = written!.bookmarks.map((b) => b.url); + expect(urls).toContain("https://safe.example/"); + expect(urls).not.toContain("javascript:evil()"); + }); + + it("does not pre-map idMap entries for unsafe-URL remote bookmarks", async () => { + // An attacker-controlled remote file has an unsafe URL. A local bookmark + // happens to have the same URL. Without the fix the idMap pre-mapping loop + // would create a phantom mapping, then applyRemoteChanges would skip the + // entry (URL guard) but idMap.save() would persist the phantom mapping, + // enabling a later tombstone to delete the user's local bookmark. + const remote: BookmarksFile = { + version: 1, + updated_at: nowIso, + bookmarks: [bm({ id: "evil-ulid", url: "javascript:evil()" })], + }; + const read = vi.fn(async () => ({ data: remote, sha: "s0", etag: "" })); + const update = vi.fn(); + const client = fakeClient({ read, update }); + + // Local tree has a bookmark at the same (unsafe) URL. + (browser.bookmarks.getTree as any).mockResolvedValueOnce([ + { id: "root", children: [ + { id: BAR, title: "Bookmarks Bar", children: [ + { id: "node-victim", parentId: BAR, title: "Victim", url: "javascript:evil()" }, + ] }, + { id: OTHER, title: "Other Bookmarks", children: [] }, + ] }, + ]); + + const idMap = await IdMap.load(); + await reconcile(client, idMap, BAR, OTHER, machineId, nowIso); + + // No phantom mapping should have been created for the unsafe-URL remote entry. + expect(idMap.nodeForUlid(asUlid("evil-ulid"))).toBeUndefined(); + }); + it("treats a 404 on read as an empty remote and pushes local-only bookmarks", async () => { // reconcile() handles the 404 from client.read() itself — it falls back to an // empty remote in-memory. It then calls updateBookmarksOrBootstrap to push @@ -140,7 +206,7 @@ describe("reconcile", () => { }); const client = fakeClient({ read, write, update }); - (chrome.bookmarks.getTree as any).mockResolvedValueOnce([ + (browser.bookmarks.getTree as any).mockResolvedValueOnce([ { id: "root", children: [ { id: BAR, title: "Bookmarks Bar", children: [ { id: "node-local", parentId: BAR, title: "Local", url: "https://local.example/" }, diff --git a/packages/extension-shared/test/settings.test.ts b/packages/extension-shared/test/settings.test.ts index f6e9def..69f6eba 100644 --- a/packages/extension-shared/test/settings.test.ts +++ b/packages/extension-shared/test/settings.test.ts @@ -20,7 +20,7 @@ describe("settings", () => { it("defaults stripTrackingParams to false when omitted in stored data", async () => { // Legacy stored settings without the field should still parse via Zod's default. - await chrome.storage.local.set({ + await browser.storage.local.set({ "gitmarks:settings": { token: "t", owner: "alice", @@ -33,7 +33,7 @@ describe("settings", () => { }); it("throws SettingsCorruptError when the stored value is malformed", async () => { - await chrome.storage.local.set({ "gitmarks:settings": { not: "valid" } }); + await browser.storage.local.set({ "gitmarks:settings": { not: "valid" } }); await expect(loadSettings()).rejects.toThrow(/invalid/); }); diff --git a/packages/extension-shared/test/setup.ts b/packages/extension-shared/test/setup.ts index 481e5e5..8d34acd 100644 --- a/packages/extension-shared/test/setup.ts +++ b/packages/extension-shared/test/setup.ts @@ -1,4 +1,5 @@ import { vi, beforeEach } from "vitest"; +import type { Bookmarks, Runtime } from "webextension-polyfill"; interface StorageBackend { data: Record; @@ -34,18 +35,18 @@ const chromeStub = { openOptionsPage: vi.fn(), sendMessage: vi.fn(), onMessage: { addListener: vi.fn() }, - lastError: undefined as chrome.runtime.LastError | undefined, + lastError: undefined as Runtime.PropertyLastErrorType | undefined, }, bookmarks: { - create: vi.fn(async (props: chrome.bookmarks.BookmarkCreateArg) => { - return { id: `mock-${Math.random().toString(36).slice(2, 10)}`, ...props } as chrome.bookmarks.BookmarkTreeNode; + create: vi.fn(async (props: Bookmarks.CreateDetails) => { + return { id: `mock-${Math.random().toString(36).slice(2, 10)}`, ...props } as Bookmarks.BookmarkTreeNode; }), - update: vi.fn(async () => ({} as chrome.bookmarks.BookmarkTreeNode)), - move: vi.fn(async () => ({} as chrome.bookmarks.BookmarkTreeNode)), + update: vi.fn(async () => ({} as Bookmarks.BookmarkTreeNode)), + move: vi.fn(async () => ({} as Bookmarks.BookmarkTreeNode)), remove: vi.fn(async () => {}), - get: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), - getTree: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), - getSubTree: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), + get: vi.fn(async () => [] as Bookmarks.BookmarkTreeNode[]), + getTree: vi.fn(async () => [] as Bookmarks.BookmarkTreeNode[]), + getSubTree: vi.fn(async () => [] as Bookmarks.BookmarkTreeNode[]), onCreated: { addListener: vi.fn(), removeListener: vi.fn() }, onChanged: { addListener: vi.fn(), removeListener: vi.fn() }, onMoved: { addListener: vi.fn(), removeListener: vi.fn() }, diff --git a/packages/extension-shared/tsconfig.json b/packages/extension-shared/tsconfig.json index 7a3e845..d59e395 100644 --- a/packages/extension-shared/tsconfig.json +++ b/packages/extension-shared/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ES2022", "DOM", "DOM.Iterable"], - "types": ["chrome"], + "types": ["webextension-polyfill"], "rootDir": "./", "outDir": "./dist-tsc", "noEmit": true diff --git a/packages/web/index.html b/packages/web/index.html index 73654e6..885e1c9 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -3,6 +3,13 @@ + gitmarks diff --git a/packages/web/src/components/Layout.tsx b/packages/web/src/components/Layout.tsx index 2a8ebb8..58ce08e 100644 --- a/packages/web/src/components/Layout.tsx +++ b/packages/web/src/components/Layout.tsx @@ -12,14 +12,18 @@ interface Props { status: LayoutStatus; onRefresh: () => void; onExport?: () => void; + onSignOut?: () => void; refreshing: boolean; + /** When true, disables the Sign out button so an in-flight write isn't + * silently committed after the user signs out. */ + busy?: boolean; } const navLinkBase = "px-3 py-1 rounded"; const navLinkActive = "bg-fog text-cyan"; const navLinkInactive = "text-cyan-soft hover:text-cyan"; -export function Layout({ children, status, onRefresh, onExport, refreshing }: Props) { +export function Layout({ children, status, onRefresh, onExport, onSignOut, refreshing, busy }: Props) { return (
@@ -62,6 +66,17 @@ export function Layout({ children, status, onRefresh, onExport, refreshing }: Pr Export )} + {onSignOut !== undefined && ( + + )}