From 24b7073dbedad41f860623a7056f32f351e37899 Mon Sep 17 00:00:00 2001 From: "mic (spark-01)" <85814106+q1@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:04:36 -0700 Subject: [PATCH 1/4] Recode Cookie Quick Manager 0.6.0 --- .github/workflows/ci.yml | 19 + .gitignore | 3 + README.md | 237 ++--- package-lock.json | 523 +++++------ package.json | 24 +- qa/PARITY_REPORT.md | 274 ++---- qa/chromium-qa.mjs | 886 ++++++++++++++++-- qa/firefox-baseline-rdp.py | 55 +- qa/fixture-server.mjs | 6 +- scripts/build.mjs | 35 + src/_locales/de/messages.json | 37 +- src/_locales/en/messages.json | 14 +- src/_locales/fr/messages.json | 14 +- src/api.js | 692 +++++--------- src/background-script.js | 273 +++--- src/browser-shim.js | 5 + src/cookies.html | 18 +- src/cookies.js | 257 +++-- src/core.js | 517 ++++++++++ src/export.js | 378 ++++---- src/manifest.firefox.json | 48 + src/manifest.json | 12 +- src/menu.html | 17 +- src/menu.js | 33 +- src/options.html | 38 +- src/options.js | 251 +++-- src/platform.js | 9 +- src/service-worker.js | 3 +- src/static/css/bootstrap-theme.min.css | 6 - src/static/css/jquery-ui.min.css | 7 - src/static/js/bootstrap-treeview-1.2.0.min.js | 1 - src/static/js/bootstrap-treeview.min.css | 1 - test/api.test.js | 109 +++ test/background.test.js | 163 ++++ test/core.test.js | 289 ++++++ test/platform.test.js | 32 + test/repository.test.js | 46 + 37 files changed, 3458 insertions(+), 1874 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 scripts/build.mjs create mode 100644 src/browser-shim.js create mode 100644 src/core.js create mode 100644 src/manifest.firefox.json delete mode 100644 src/static/css/bootstrap-theme.min.css delete mode 100644 src/static/css/jquery-ui.min.css delete mode 100644 src/static/js/bootstrap-treeview-1.2.0.min.js delete mode 100644 src/static/js/bootstrap-treeview.min.css create mode 100644 test/api.test.js create mode 100644 test/background.test.js create mode 100644 test/core.test.js create mode 100644 test/platform.test.js create mode 100644 test/repository.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ba7b849 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run check diff --git a/.gitignore b/.gitignore index b6288b1..2a75508 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ dist/ build/ +build-firefox/ node_modules/ qa/certs/ +__pycache__/ +*.py[cod] qa/firefox-baseline-extension/ .playwright-cqm-profile/ poc/ diff --git a/README.md b/README.md index e4638cd..19779a1 100644 --- a/README.md +++ b/README.md @@ -1,211 +1,140 @@ -# Overview +# Cookie Quick Manager -Cookie Quick Manager is a complete manager for cookies accumulated during browsing. -It allows you to view, edit, create, delete, backup, restore, and search cookies by domain names. -The current codebase is being ported to Chromium as a Manifest V3 extension while preserving the original Firefox-oriented feature set as closely as Chromium allows. -In addition, the LocalStorage of the page viewed can be deleted (see below). +Cookie Quick Manager is a privacy-oriented browser extension for viewing, searching, creating, editing, deleting, protecting, exporting, and importing cookies. It also provides a popup action for clearing the active page's LocalStorage. -Cookie Quick Manager is designed for developers, testers or people -concerned about their privacy on the Internet. +This fork modernizes the original extension as a shared Manifest V3 codebase with target-specific builds for: -This WebExtension is compatible with Firefox 57 and is inspired by addons like [Cookies Manager+](https://addons.mozilla.org/fr/firefox/addon/cookies-manager-plus/) and [Advanced Cookie Manager](https://addons.mozilla.org/fr/firefox/addon/cookie-manager/) whose development has been discontinued due to the withdrawal of the support for "Legacy" extensions. +- Chromium 130+ +- Firefox desktop 140+ +- Firefox for Android 142+ -**November 2018: Cookie Quick Manager is now available on Android!** +The project remains GPLv3 and retains attribution to the original Cookie Quick Manager project by Ysard. -

- -

+## What it supports +- Host-only and domain-scoped cookies +- Session and persistent cookies +- Secure, HttpOnly, and SameSite attributes +- Exact path, store, host/domain, First-Party Isolation, and partition identity +- Chromium partitioned cookies (CHIPS), including visible partition scope +- Firefox containers/contextual identities and First-Party Isolation controls +- Search by domain, name, and value, with optional subdomain grouping +- JSON and Netscape `cookies.txt` import/export +- Exact cookie protection and restoration after an explicit site deletion +- Bulk deletion of only the cookies currently visible through the active filters +- Split-incognito Chromium stores when the user enables Allow in Incognito +- Popup counts and exact-origin LocalStorage clearing -# Chromium / Chrome status +Natural cookie expiry and browser eviction are intentionally not reversed. Protection is for explicit deletion, not a way to make expired cookies immortal. -This branch now targets **Chromium Manifest V3**. +## Browser-specific behavior -Most of the original functionality can be preserved, but Chromium does not expose exact equivalents for two Firefox-only capabilities: +Chromium uses an extension service worker and generic cookie stores. Firefox uses background scripts and retains its container and privacy APIs. The Firefox-only First-Party Isolation UI is hidden on Chromium. -* **Containers / Contextual Identities** → replaced by generic **cookie stores** (default store, and a separate incognito/private store when Chromium allows it). -* **First-Party Isolation** → Firefox-only. The related Firefox setting is not exposed on Chromium, so that UI is hidden there. +The manager opens in a tab by default. Windowed mode uses a normal extension popup window. Chromium users must enable Allow in Incognito before private-store cookies are available to the extension. -Other important Chromium-specific notes: +## Install and build -* The background logic now runs in an **extension service worker**. -* The old Firefox panel window behavior is replaced by opening the manager in a **tab** by default, or a regular Chromium **popup window** when windowed mode is selected. -* To manage private/incognito cookies in Chromium, users must enable **Allow in Incognito** for the extension. - - -# Development / local loading - -## Build the Chromium extension +The project explicitly uses npm: ```bash -npm install -npm run build +npm ci +npm run build:all ``` -This creates a loadable unpacked extension in `build/`. - -## Load unpacked in Chromium - -1. Open `chrome://extensions` -2. Enable **Developer mode** -3. Click **Load unpacked** -4. Select the `build/` directory +Artifacts: -## Local QA fixture site +- `build/` — unpacked Chromium extension +- `build-firefox/` — unpacked Firefox extension +- `dist/cookie_quick_manager-chromium.zip` — after `npm run package` +- `dist/cookie_quick_manager-firefox.zip` — after `npm run package:firefox` -The repository includes a local fixture server used to QA cookie, LocalStorage, subdomain, and protection behavior deterministically. +For Chromium, open `chrome://extensions`, enable Developer mode, choose Load unpacked, and select `build/`. -Generate a development certificate once: +For Firefox, load `build-firefox/` temporarily from `about:debugging`, or run: ```bash -npm run fixture:cert +npx web-ext run --source-dir=build-firefox --firefox=/usr/bin/firefox --no-input ``` -Start the HTTP + HTTPS fixture server: +## Test and release checks + +Run the complete local gate: ```bash -npm run fixture:start +npm run check ``` -Fixture entry points: - -* `http://lvh.me:4173/` -* `http://sub.lvh.me:4173/` -* `https://lvh.me:4443/` -* `https://sub.lvh.me:4443/` +This gate: -`lvh.me` and its subdomains resolve to `127.0.0.1`, which makes them convenient for domain/subdomain cookie testing. +- runs the browser-independent unit and adapter contracts; +- syntax-checks source, QA, and build scripts; +- builds Chromium and Firefox artifacts; +- lints the Firefox artifact with `web-ext`; +- launches the unpacked Chromium extension in Playwright and runs the full browser regression suite. -## Focused Chromium QA +The Chromium QA runner generates its ignored development certificate when needed, starts the loopback-only fixture server, maps `lvh.me` locally in Chromium, and stops the fixture process it owns. A separately running fixture is reused. -Run the current automated Chromium regression checks: +Focused commands: ```bash +npm run test:unit npm run qa:chromium +npm test ``` -The script loads the unpacked MV3 extension into Playwright Chromium and verifies: +The browser suite covers real manager, options, import/export, protection, and popup paths. Its scenarios include exact host/domain and path collisions, stale-value rotation, site-specific parent-domain filtering, aging backups, nameless cookies, uppercase/BOM Netscape files, partition round-trips and UI disambiguation, settings replacement/reset, hostile markup, and actual popup LocalStorage clearing. -* fixture seeding on the QA site -* domain listing in the manager UI -* protected-cookie restoration -* cookie editing -* JSON export/import round-trip -* secure cookie creation on HTTPS -* Chromium options behavior (including hidden Firefox-only FPI control) +See [qa/PARITY_REPORT.md](qa/PARITY_REPORT.md) for dated evidence and the remaining browser matrix limitations. -## Firefox baseline parity check +### Manual fixture use -To compare against the old Firefox build: - -1. Extract / launch the baseline extension with `web-ext`, for example: - -```bash -unzip -q -o dist/cookie_quick_manager-0.5rc2.zip -d qa/firefox-baseline-extension -npx web-ext run --source-dir=qa/firefox-baseline-extension --firefox=/usr/bin/firefox --start-url=http://lvh.me:4173/ --no-input -``` - -2. In another terminal, run the remote-debugging parity probe: +The fixture can also be run interactively: ```bash -python3 qa/firefox-baseline-rdp.py +npm run fixture:cert +npm run fixture:start ``` -See `qa/PARITY_REPORT.md` for the latest recorded parity summary. - - -# Features - -* User friendly: Clear and structured user interface. Each parameter and functionality is described when the mouse is over the element. -* Windowed and tab mode: Choose the opening in a tab to get a wider view. -* Transparency and security: The source code is free (under GPLv3) and # published on a public platform, the only way to allow reviews and external contributions. -* Search: A user can search for cookies of a domain and subdomains which depend on it. -* Edit/Create: All the attributes of a cookie can be modified: domain, path, name, value, expiration date, as well as secure and httponly flags. -* Delete: Remove the cookies of the current website in two clicks. -* Export: The export and import of a cookie or cookies from a domain in JSON or Netscape format is just as easy. -* First-Party Isolation: Supported with some limitations (due to API bugs) on Firefox 59, 60, and 61, and without limitations on Firefox 62 (scheduled on September 2018). -* Stores / contexts: On Firefox, contextual identities (containers) are supported. On Chromium, the equivalent UI works with browser cookie stores instead. -* SameSite: The SameSite flag is supported. This is a partial protection against the risks associated with Cross-Site Request Forgery (CSRF) and Cross-Site Script Inclusion (XSSI) attacks, implemented since Firefox 63. -* Cookie protection: Delete cookies except protected ones, with two clicks at anytime from the website you are viewing. An option can also prevent cookies from being deleted by the sites themselves. -* Protection of session cookies: Session cookies can be protected in two clicks to prevent accidental logout from websites after cleaning normal cookies. -* Cleaning and privacy: Can automatically delete all cookies at startup. -* LocalStorage: Keys/values of the page viewed can be deleted. - - -# Privacy - -This addon does not store or leak any personal information. - -It requires the following permissions to operate: - -* Host permission for all urls: This allows you to edit the cookies and to delete the Localstorage of any site visited. -* Cookies: Allows access to the browser's cookie store. -* ActiveTab: Allows access to the currently consulted url, and its favicon if it exists. -* Scripting: Used on Chromium to read the LocalStorage item count from the current tab. -* Storage: Allows the storage of the following user settings: - - size of the windowed mode, - - protected cookies (only the name of the domains), - - the template used to import/export the cookies, - - the skin. -* Browsing data: Enables extensions to clear the data that is accumulated while the user is browsing (LocalStorage here). -* Contextual Identities: Firefox only. Allows the addon to list the containers. -* Privacy: Firefox only. Access and modify various privacy-related browser settings (the FirstPartyIsolation flag here). -* ClipboardWrite (optional): Allows the export of cookies to the clipboard from Firefox 63. - - -# About cookie protection - -The protection of cookies is limited to the current addon actions, to the deletions that can be made by the sites themselves, or to the deletions made in the browser's "Cookies and Site Data" options. -This means that if you choose the browser option to delete all cookies when it closes, the addon will be unable to restore them (the method used by the browser does not send the necessary signal to notify the addon). However, a similar option is reimplemented in the addon itself in order to keep only the protected cookies when restarting the browser. - - -# What is "Delete Current Site Local Storage" on the popup menu? - -This item allows a user to delete LocalStorage keys from the viewed page. - -The LocalStorage is a quite new feature of HTML5 that allows developers to create data in your browser using JavaScript. Cookies are just one type of storage among others. -You will also find the term "SessionStorage", a LocalStorage where data is stored temporarily (deleted on browser restart), but the important thing is that LocalStorage is persistent, and cleared only at the discretion of the visited websites. -You may erase all of the LocalStorage store by following the procedure described in the [documentation of Firefox](https://support.mozilla.org/en-US/kb/delete-cookies-remove-info-websites-stored#w_delete-all-cookies), and by selecting "Offline Website Data". - -From the point of view of privacy and security: - -This kind of persistant data (even after clearing cache), was invented to store small data allowing the operation of online applications, but also offers new and better accurate ways of tracking thanks to the memorization of private or identifying data. - - -# Support & source code - -The extension is still in development with the launch of Firefox Quantum; questions, bug reports and feature requests are open on the [GitHub repository](https://github.com/ysard/cookie-quick-manager/issues). +Entry points: +- `http://lvh.me:4173/` +- `http://sub.lvh.me:4173/` +- `https://lvh.me:4443/` +- `https://sub.lvh.me:4443/` -# How to contribute ? +The server binds to `127.0.0.1` by default. -1. You can contribute by reporting bugs or problems encountered by creating an issue on [this page] (https://github.com/ysard/cookie-quick-manager/issues) +## Privacy and permissions +The extension does not transmit browsing data or cookie contents. Settings and cookie-protection identity metadata are stored locally. Cookie values are not placed in extension storage. -2. You can translate the application into your language. -The files are at this address: -https://github.com/ysard/cookie-quick-manager/tree/fpi/src/_locales +Permissions are used as follows: -You must fork the repository and then make a pull-request with your changes. -See this documentation with illustrations: +- `cookies` and `` — inspect and modify cookies for sites the user visits +- `activeTab` and `scripting` — count and clear LocalStorage for the exact active origin +- `storage` — settings and protected-cookie identity metadata +- `clipboardWrite` (optional) — copy exported cookies +- `contextualIdentities` and `privacy` (Firefox only) — containers and First-Party Isolation -- https://help.github.com/en/articles/fork-a-repo -- https://help.github.com/en/articles/creating-a-pull-request-from-a-fork +Protection metadata includes domain, name, path, store, host/domain scope, and partition/FPI scope. Chromium shares `storage.local` between regular and split-incognito extension processes, but the cookie jars remain separate. -Then execute the following commands: +Browser-level clear-on-close policies may delete cookies before an extension can preserve them. The add-on's own startup cleanup instead retains protected identities. - cd cookie-quick-manager - make get_missing_from_new_language LOCALE = "from" +## Compatibility notes -(here, `de` is the locale code for Deutch language) +- Current JSON exports retain store, FPI, SameSite, host/domain, and partition metadata. +- Older exports containing malformed URLs such as `https://.example.com/` are repaired during import. +- Structurally valid expired persistent records are skipped while the rest of an aging backup is restored. +- Netscape import accepts standard case-insensitive `TRUE`/`FALSE` flags and `#HttpOnly_` entries. +- Firefox uses a fork-specific extension ID and declares no data collection in its manifest. -The previous command will create a file in `src/_locales//messages.json`, -then display the list of elements to be translated. -The file does not need to be translated to 100% to be used! +## Contributing -You must then make your pull request on github. +Issues and pull requests belong at [q1/cookie-quick-manager](https://github.com/q1/cookie-quick-manager). Translation files are under `src/_locales/`. +The historical upstream is [ysard/cookie-quick-manager](https://github.com/ysard/cookie-quick-manager). -# License +## License -[GPLv3](https://github.com/ysard/cookie-quick-manager/blob/master/LICENSE"). \ No newline at end of file +[GNU General Public License v3.0](LICENSE) diff --git a/package-lock.json b/package-lock.json index 1daf09b..d8b2236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "cookie-quick-manager", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cookie-quick-manager", - "version": "0.5.0", + "version": "0.6.0", "devDependencies": { - "playwright": "^1.58.2", - "web-ext": "^10.0.0", - "webextension-polyfill": "^0.12.0" + "playwright": "^1.61.1", + "web-ext": "^10.5.0" } }, "node_modules/@babel/code-frame": { @@ -39,14 +38,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.0.tgz", + "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } + "license": "MIT" }, "node_modules/@devicefarmer/adbkit": { "version": "3.3.8", @@ -174,9 +170,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, "license": "MIT", "dependencies": { @@ -186,7 +182,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -198,9 +194,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -266,9 +262,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -324,29 +320,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -376,9 +386,9 @@ } }, "node_modules/@mdn/browser-compat-data": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-7.3.6.tgz", - "integrity": "sha512-eLTmNSxv2DaDO1Hq7C9lwQbThlF5+vMMUQfIl6xRPSC2q6EcFXhim4Mc9uxHNxcPvDFCdB3qviMFDdAzQVhYcw==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-8.0.4.tgz", + "integrity": "sha512-up6DsNsaPt3jq5d2TGx4UOXIqBKU0D86dxWwcVmmOOEYqP1rFaV1XcAcwHxMvb6bbtMfY5JpjUCszzy/aVc4yA==", "dev": true, "license": "CC0-1.0" }, @@ -435,9 +445,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -466,9 +476,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -489,24 +499,24 @@ } }, "node_modules/addons-linter": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/addons-linter/-/addons-linter-10.1.0.tgz", - "integrity": "sha512-Qo8QE/tGxaGMTQGiLPGfxDyrYJCKtsXFkyto3UGuVPb2V+Jc725U3Jjpwpo7cXoImCebueUVXC8KC8D7dpacTQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/addons-linter/-/addons-linter-10.8.0.tgz", + "integrity": "sha512-AotpGWlp5FrmUIH8kjyYjQvCmthnSqPLpw7kOEl7DfoAWEndOCV/ONuekAVM8QObiei802RyyIClEaekA51n5g==", "dev": true, "license": "MPL-2.0", "dependencies": { "@fluent/syntax": "0.19.0", "@fregante/relaxed-json": "2.0.0", - "@mdn/browser-compat-data": "7.3.6", + "@mdn/browser-compat-data": "8.0.4", "addons-moz-compare": "1.3.0", - "addons-scanner-utils": "13.1.0", - "ajv": "8.18.0", - "chalk": "4.1.2", + "addons-scanner-utils": "15.4.0", + "ajv": "8.20.0", "cheerio": "1.2.0", "columnify": "1.6.0", "common-tags": "1.8.2", + "css-tree": "3.2.1", "deepmerge": "4.3.1", - "eslint": "9.39.2", + "eslint": "9.39.4", "eslint-plugin-no-unsanitized": "4.1.5", "eslint-visitor-keys": "5.0.1", "espree": "11.2.0", @@ -515,11 +525,11 @@ "image-size": "2.0.2", "json-merge-patch": "1.0.2", "pino": "10.3.1", - "semver": "7.7.4", + "semver": "7.8.5", "source-map-support": "0.5.21", - "upath": "2.0.1", + "upath": "3.0.7", "yargs": "17.7.2", - "yauzl": "3.2.1" + "yauzl": "3.4.0" }, "bin": { "addons-linter": "bin/addons-linter" @@ -536,9 +546,9 @@ "license": "MPL-2.0" }, "node_modules/addons-scanner-utils": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/addons-scanner-utils/-/addons-scanner-utils-13.1.0.tgz", - "integrity": "sha512-4apAnr0xrEVbIJhNPQA6XUnVAiUR0/lt8EvflrPgrmBQYwTwo7z+yv92t7F1iO2Y07lNhDCZEoL8q1H4sLJyGw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/addons-scanner-utils/-/addons-scanner-utils-15.4.0.tgz", + "integrity": "sha512-i3PnJx9YLZi96o4t4JWArP6JimC01949YVAcMJKHfyQ5qmUBaH21qNS8f12/RsloejW8IcCEljhh3Fq3gxFNsg==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -546,18 +556,14 @@ "first-chunk-stream": "3.0.0", "jsonwebtoken": "^9.0.3", "strip-bom-stream": "4.0.0", - "upath": "2.0.1", - "yauzl": "3.2.1" + "upath": "3.0.7", + "yauzl": "3.4.0" }, "peerDependencies": { - "body-parser": "2.2.2", "express": "5.2.1", "safe-compare": "1.1.4" }, "peerDependenciesMeta": { - "body-parser": { - "optional": true - }, "express": { "optional": true }, @@ -587,9 +593,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -780,9 +786,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -790,16 +796,6 @@ "concat-map": "0.0.1" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1072,22 +1068,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "dev": true, - "engines": [ - "node >= 0.8" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -1164,6 +1144,20 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", @@ -1461,25 +1455,25 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -1498,7 +1492,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -1561,9 +1555,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1722,9 +1716,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "dev": true, "funding": [ { @@ -1850,17 +1844,15 @@ } }, "node_modules/fx-runner": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/fx-runner/-/fx-runner-1.4.0.tgz", - "integrity": "sha512-rci1g6U0rdTg6bAaBboP7XdRu01dzTAaKXxFf+PUqGuCv6Xu7o8NZdY1D5MvKGIjb6EdS1g3VlXOgksir1uGkg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/fx-runner/-/fx-runner-1.5.0.tgz", + "integrity": "sha512-EstfdRQu04tM+SXR3pBmc0+GYKZj9FMHAvA8XAbKPzTvUeHP0v1/F3aQ10bG7EluR6phvFqsvn5Z7SvYJsEgNw==", "dev": true, "license": "MPL-2.0", "dependencies": { - "commander": "2.9.0", - "shell-quote": "1.7.3", - "spawn-sync": "1.0.15", - "when": "3.7.7", - "which": "1.2.4", + "commander": "^12.1.0", + "shell-quote": "1.8.4", + "which": "^4.0.0", "winreg": "0.0.12" }, "bin": { @@ -1868,37 +1860,39 @@ } }, "node_modules/fx-runner/node_modules/commander": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", - "integrity": "sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, "license": "MIT", - "dependencies": { - "graceful-readlink": ">= 1.0.0" - }, "engines": { - "node": ">= 0.6.x" + "node": ">=18" } }, "node_modules/fx-runner/node_modules/isexe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-1.1.2.tgz", - "integrity": "sha512-d2eJzK691yZwPHcv1LbeAOa91yMJ9QmfTgSO1oXB65ezVhXQsxBac2vEB4bMVms9cGzaA99n6V2viHMq82VLDw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } }, "node_modules/fx-runner/node_modules/which": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/which/-/which-1.2.4.tgz", - "integrity": "sha512-zDRAqDSBudazdfM9zpiI30Fu9ve47htYXcGi3ln0wfKu2a7SmrT6F3VDoYONu//48V8Vz4TdCRNPjtvyRO3yBA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", "dev": true, "license": "ISC", "dependencies": { - "is-absolute": "^0.1.7", - "isexe": "^1.1.1" + "isexe": "^3.1.1" }, "bin": { - "which": "bin/which" + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" } }, "node_modules/get-caller-file": { @@ -1937,13 +1931,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", @@ -1990,20 +1977,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graceful-readlink": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz", - "integrity": "sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==", - "dev": true, - "license": "MIT" - }, - "node_modules/growly": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", - "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==", - "dev": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2161,19 +2134,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/is-absolute": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-0.1.7.tgz", - "integrity": "sha512-Xi9/ZSn4NFapG8RP98iNPMOeaV3mXPisxKxzKtHVqr3g56j/fBn+yZmnxSVAA8lmZbl2J9b/a4kJvfU3hqQYgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-relative": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -2330,15 +2290,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-relative": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-0.1.3.tgz", - "integrity": "sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-utf8": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", @@ -2391,10 +2342,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -2684,6 +2645,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2741,30 +2709,15 @@ "license": "MIT" }, "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", "dev": true, "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" } }, - "node_modules/node-notifier": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz", - "integrity": "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "growly": "^1.3.0", - "is-wsl": "^2.2.0", - "semver": "^7.3.5", - "shellwords": "^0.1.1", - "uuid": "^8.3.2", - "which": "^2.0.2" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -2827,15 +2780,6 @@ "node": ">= 0.8.0" } }, - "node_modules/os-shim": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/os-shim/-/os-shim-0.1.3.tgz", - "integrity": "sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A==", - "dev": true, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3053,13 +2997,13 @@ "license": "MIT" }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -3072,9 +3016,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3371,9 +3315,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -3414,18 +3358,17 @@ } }, "node_modules/shell-quote": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", - "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, - "license": "MIT" - }, - "node_modules/shellwords": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", - "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/sonic-boom": { "version": "4.2.1", @@ -3447,6 +3390,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -3458,18 +3411,6 @@ "source-map": "^0.6.0" } }, - "node_modules/spawn-sync": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz", - "integrity": "sha512-9DWBgrgYZzNghseho0JOuh+5fg9u6QWhAWa51QC7+U5rCheZ/j1DrEZnyE0RBBRqZ9uEXGPgSSM0nky6burpVw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "concat-stream": "^1.4.7", - "os-shim": "^0.1.2" - } - }, "node_modules/split": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", @@ -3654,18 +3595,25 @@ } }, "node_modules/thread-stream": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", - "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", "dev": true, "license": "MIT", "dependencies": { - "real-require": "^0.2.0" + "real-require": "^1.0.0" }, "engines": { "node": ">=20" } }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "dev": true, + "license": "MIT" + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -3674,9 +3622,9 @@ "license": "MIT" }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -3709,17 +3657,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "dev": true, - "license": "MIT" - }, "node_modules/undici": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", - "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -3744,14 +3685,27 @@ } }, "node_modules/upath": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", - "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/upath/-/upath-3.0.7.tgz", + "integrity": "sha512-VjBBquch25nUGMuVBpOb2Cj3gc8Kb7lJBqbsXR/0anZ/5uJsL14Kpth9JKfnBsckxCfgIp6hPvcvvmZ97R9X7g==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/anodynos" + }, + { + "type": "polar", + "url": "https://polar.sh/anodynos" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-upath" + } + ], "license": "MIT", "engines": { - "node": ">=4", - "yarn": "*" + "node": ">=20" } }, "node_modules/update-notifier": { @@ -3809,24 +3763,13 @@ "dev": true, "license": "MIT" }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "dev": true, "license": "MIT", "dependencies": { - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" }, "engines": { @@ -3844,27 +3787,26 @@ } }, "node_modules/web-ext": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/web-ext/-/web-ext-10.0.0.tgz", - "integrity": "sha512-Cdr7GELRVOXGdV8NqZAPa9oImXWUMDbzYff+UBx5F6mz4zfIrnF3+iYUsvBfG9VJ/VsqmXKdpt1gBsXbH8Z75A==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/web-ext/-/web-ext-10.5.0.tgz", + "integrity": "sha512-tveUHhZHu5A2Qrs7u+hbh2S+7ZPuKVH/ILzqkRwpdivFOnVmgPhDLtmtGNKT3a8N9PZbW5uP6INe6Byj5qePVQ==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@babel/runtime": "7.28.6", + "@babel/runtime": "8.0.0", "@devicefarmer/adbkit": "3.3.8", - "addons-linter": "10.1.0", + "addons-linter": "10.8.0", "camelcase": "8.0.0", "chrome-launcher": "1.2.0", "debounce": "1.2.1", "decamelize": "6.0.1", "es6-error": "4.1.1", "firefox-profile": "4.7.0", - "fx-runner": "1.4.0", + "fx-runner": "1.5.0", "https-proxy-agent": "^7.0.0", "jose": "5.9.6", "jszip": "3.10.1", "multimatch": "6.0.0", - "node-notifier": "10.0.1", "open": "11.0.0", "parse-json": "8.3.0", "pino": "10.3.1", @@ -3872,9 +3814,9 @@ "source-map-support": "0.5.21", "strip-bom": "5.0.0", "strip-json-comments": "5.0.3", - "tmp": "0.2.5", + "tmp": "0.2.7", "update-notifier": "7.3.1", - "watchpack": "2.5.1", + "watchpack": "2.5.2", "yargs": "17.7.2", "zip-dir": "2.0.0" }, @@ -3886,13 +3828,6 @@ "npm": ">=8.0.0" } }, - "node_modules/webextension-polyfill": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/webextension-polyfill/-/webextension-polyfill-0.12.0.tgz", - "integrity": "sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==", - "dev": true, - "license": "MPL-2.0" - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -3917,13 +3852,6 @@ "node": ">=18" } }, - "node_modules/when": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/when/-/when-3.7.7.tgz", - "integrity": "sha512-9lFZp/KHoqH6bPKjbWqa+3Dg/K/r2v0X/3/G2x4DBGchVS2QX2VXL3cZV994WQVnTM1/PD71Az25nAzryEUugw==", - "dev": true, - "license": "MIT" - }, "node_modules/when-exit": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", @@ -4172,13 +4100,12 @@ } }, "node_modules/yauzl": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.1.tgz", - "integrity": "sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", "dev": true, "license": "MIT", "dependencies": { - "buffer-crc32": "~0.2.3", "pend": "~1.2.0" }, "engines": { diff --git a/package.json b/package.json index 7cf5d32..cb1e00d 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,25 @@ { "name": "cookie-quick-manager", - "version": "0.5.0", + "version": "0.6.0", "private": true, - "description": "Chromium MV3 build tooling for Cookie Quick Manager", + "description": "Dual-target Chromium and Firefox MV3 Cookie Quick Manager", "scripts": { - "clean": "rm -rf build", - "build": "npm run clean && mkdir -p build/static/js && cp -R src/* build/ && cp LICENSE build/ && cp node_modules/webextension-polyfill/dist/browser-polyfill.min.js build/static/js/browser-polyfill.min.js", - "package": "npm run build && mkdir -p dist && (cd build && zip -qr ../dist/cookie_quick_manager-chromium.zip .)", + "clean": "rm -rf build build-firefox", + "build": "npm run build:chromium", + "build:chromium": "node scripts/build.mjs chromium build", + "build:firefox": "node scripts/build.mjs firefox build-firefox", + "build:all": "npm run build:chromium && npm run build:firefox", + "package": "npm run build:chromium && mkdir -p dist && rm -f dist/cookie_quick_manager-chromium.zip && (cd build && zip -qr ../dist/cookie_quick_manager-chromium.zip .)", + "package:firefox": "npm run build:firefox && mkdir -p dist && rm -f dist/cookie_quick_manager-firefox.zip && (cd build-firefox && zip -qr ../dist/cookie_quick_manager-firefox.zip .)", "fixture:cert": "bash qa/generate-dev-cert.sh", "fixture:start": "node qa/fixture-server.mjs", - "qa:chromium": "npm run build && node qa/chromium-qa.mjs" + "qa:chromium": "npm run build && node qa/chromium-qa.mjs", + "test:unit": "node --test test/*.test.js", + "test": "npm run test:unit && npm run qa:chromium", + "check": "npm run test:unit && for file in src/*.js qa/*.mjs scripts/*.mjs; do node --check \"$file\"; done && python3 -c \"import ast, pathlib; ast.parse(pathlib.Path('qa/firefox-baseline-rdp.py').read_text())\" && npm run build:all && web-ext lint --source-dir build-firefox && node qa/chromium-qa.mjs" }, "devDependencies": { - "playwright": "^1.58.2", - "web-ext": "^10.0.0", - "webextension-polyfill": "^0.12.0" + "playwright": "^1.61.1", + "web-ext": "^10.5.0" } } diff --git a/qa/PARITY_REPORT.md b/qa/PARITY_REPORT.md index fc8105d..47ee8ee 100644 --- a/qa/PARITY_REPORT.md +++ b/qa/PARITY_REPORT.md @@ -1,185 +1,111 @@ -# Chromium MV3 Parity Report +# Cookie Quick Manager 0.6.0 QA and compatibility report -Date: 2026-03-22 +Date: 2026-07-11 -This report summarizes the parity-focused QA work completed for the Chromium/Manifest V3 port against the original Firefox extension behavior. +This report records evidence for the current dual-target recode. It deliberately separates current-artifact verification from the historical Firefox baseline; Firefox lint alone is not treated as runtime parity. -## Test setup +## Target artifacts -### Fixture environment +- Chromium Manifest V3, minimum Chrome 130 +- Firefox Manifest V3, minimum Firefox desktop 140 +- Firefox for Android, minimum Firefox 142 +- Package and both manifests: version 0.6.0 -Deterministic fixture pages were used instead of arbitrary live sites: +The Chromium and Firefox ZIPs were rebuilt from clean target directories and verified byte-for-byte against every file in those directories. -* HTTP: `http://lvh.me:4173/` -* HTTPS: `https://lvh.me:4443/` -* subdomain variants on `sub.lvh.me` +## Automated release gate -These fixtures support: - -* host-only and domain cookies -* page-driven cookie deletion -* LocalStorage seeding/clearing -* secure-cookie testing on HTTPS - -### Baseline Firefox extension - -The original Firefox artifact from `dist/cookie_quick_manager-0.5rc2.zip` was extracted and launched with `web-ext` for comparison. - -### Chromium target - -The ported extension was loaded from `build/` as an unpacked Manifest V3 Chromium extension. - -## Chromium verification performed - -### Manual GUI smoke checks - -Confirmed manually in Chromium: - -* unpacked extension loads successfully in `chrome://extensions` -* popup opens successfully -* site-specific popup entries appear on the fixture page -* popup counts looked plausible for: - * current-site cookies - * current-store cookies - * current-site LocalStorage -* manager page opens and lists seeded fixture cookies - -### Automated Chromium QA - -Executed with: +Command: ```bash -npm run qa:chromium +npm run check ``` -Passing checks: - -* fixture seeding -* manager domain listing -* auto-refresh when a same-domain cookie is created outside the manager -* subdomain grouping -* search filtering by cookie name -* protected-cookie restoration after page-driven deletion -* cookie editing -* JSON export -* direct JSON restore -* delete cookie -* JSON import round-trip -* secure cookie creation on HTTPS -* hidden Firefox-only FPI control on Chromium options page -* representative options persistence - -Latest successful result set: - -```json -{ - "ok": true, - "results": [ - {"check": "fixture-seeding", "status": "passed"}, - {"check": "manager-domain-list", "status": "passed"}, - {"check": "auto-refresh", "status": "passed"}, - {"check": "group-subdomains", "status": "passed"}, - {"check": "search-filter-by-name", "status": "passed"}, - {"check": "protected-cookie-restore", "status": "passed"}, - {"check": "edit-cookie", "status": "passed"}, - {"check": "export-cookie-json", "status": "passed"}, - {"check": "direct-json-restore", "status": "passed"}, - {"check": "delete-cookie", "status": "passed"}, - {"check": "import-cookie-json", "status": "passed"}, - {"check": "create-secure-cookie", "status": "passed"}, - {"check": "options-hide-fpi", "status": "passed"}, - {"check": "options-persistence", "status": "passed"} - ] -} -``` - -## Firefox baseline verification performed - -The Firefox baseline was inspected through Firefox's remote debugging protocol against the temporary add-on session launched by `web-ext`. - -Executed with: - -```bash -python3 qa/firefox-baseline-rdp.py -``` - -Passing checks: - -* fixture seeding -* manager page opens -* manager lists seeded `.lvh.me` and `lvh.me` domains -* subdomain grouping collapses host/domain variants into one grouped domain -* name-based search filtering isolates the expected cookie -* host-only cookie can be selected from the manager -* single-cookie protection state can be normalized to locked -* protected host cookie survives page-driven deletion after the protection state is normalized - -Latest successful result set: - -```json -{ - "ok": true, - "results": [ - {"check": "fixture-seeding", "status": "passed"}, - {"check": "manager-domain-list", "status": "passed"}, - {"check": "group-subdomains", "status": "passed"}, - {"check": "search-filter-by-name", "status": "passed"}, - {"check": "select-host-cookie", "status": "passed"}, - {"check": "protect-state-normalized", "status": "passed"}, - {"check": "protected-cookie-delete-from-page-js", "status": "passed"} - ] -} -``` - -## Parity conclusions - -### Verified equivalent or acceptably equivalent behavior - -* manager page opens successfully in both Firefox baseline and Chromium port -* seeded host-only/domain cookies are visible in the manager -* subdomain grouping matches between Firefox baseline and Chromium -* name-based search filtering matches between Firefox baseline and Chromium -* single-cookie protection works and prevents page-driven deletion -* auto-refresh works in Chromium for same-domain external cookie mutations -* cookie edit flow works in Chromium -* JSON export/import round-trip works in Chromium -* secure cookie creation works in Chromium on HTTPS fixtures -* representative options persistence works in Chromium - -### Intentional Chromium substitutions - -These are expected platform substitutions, not regressions: - -* **Firefox containers / contextual identities** → **Chromium cookie stores** -* **Firefox FPI UI / setting** → hidden on Chromium -* **Firefox panel window behavior** → tab by default, standard popup window when windowed mode is selected - -### Remaining QA limitations - -The highest-value parity scenarios are covered, but not every historical UI path was fully automated on both browsers. - -Not exhaustively automated yet: - -* Firefox popup quick-action counts -* full Firefox export/import round-trip through UI -* Firefox options persistence matrix -* incognito/store comparison matrix on both browsers - -These are follow-up QA opportunities rather than known failures. - -## Overall signoff - -The Chromium port now has strong evidence for core parity on the most critical user-facing workflows: - -* cookie discovery/listing -* search filtering -* subdomain grouping -* auto-refresh -* single-cookie editing -* single-cookie protection and restore behavior -* JSON import/export -* secure cookie creation -* representative options persistence -* Chromium-specific platform substitutions documented clearly - -At this point, the Chromium MV3 port is in good shape for a parity-first release candidate, with deeper modernization intentionally deferred until after this parity pass. +The gate is self-contained. Starting without `qa/certs/` or a fixture process, it generated a development certificate, started the fixture on loopback, ran the browsers, and stopped the process it owned. + +Latest clean result: + +- 34/34 browser-independent unit, core, adapter, manifest, and locale contracts passed +- source, QA, build-script, and Firefox RDP probe syntax checks passed +- Chromium and Firefox builds passed +- Firefox `web-ext lint`: 0 errors, 0 warnings, 2 known-library notices +- 35/35 Chromium Playwright workflows passed +- `npm audit`: 0 vulnerabilities +- `git diff --check`: passed + +The final 35-scenario Chromium suite also passed in a separate repeat execution. + +## Chromium runtime coverage + +The suite loads the unpacked extension into headless Chromium and exercises production UI/adapters, not test-only reimplementations. + +Passing scenarios: + +1. Fixture cookie seeding +2. Manager domain listing +3. Auto-refresh on additions and website expiry tombstones +4. Subdomain grouping +5. Name filtering +6. Protected host-cookie restoration after website deletion +7. Cookie value editing +8. Single-cookie JSON export and count rendering +9. Exact unprotect/delete +10. JSON import through the real file handler +11. Domain-cookie editing +12. Protected domain-cookie attribute restoration +13. Fresh-value rotation winning over a stale pending restore +14. Exact protected path identity +15. Exact unprotected `/`, `/app`, and unusual-path deletion +16. Host-only/domain sibling collision restore and deletion +17. Delete-key safety inside editors +18. Filtered bulk deletion leaving non-visible cookies untouched +19. Domain JSON round-trip with quotes, backslashes, template tokens, and Unicode +20. Full pre-mutation validation of invalid multi-record imports +21. Aging backups: live nameless cookie restored, expired persistent record skipped +22. Netscape host/domain, HttpOnly, Secure, uppercase flags, BOM, and default-store round-trip +23. Partitioned cookie edit/export/import/delete +24. Same-site partition identities with opposite ancestor bits shown distinctly +25. Actual subdomain grouping +26. Site-specific launch including applicable parent-domain cookies but not host-only parents +27. Domain label-boundary handling +28. Prototype-named intranet domain safety +29. Actual popup cookie/LocalStorage counts and LocalStorage clearing +30. Secure SameSite=None creation on HTTPS +31. Chromium options hiding Firefox-only FPI controls +32. Options persistence +33. Privileged settings-tree markup rendered literally +34. Settings restore replacing the snapshot and removing stale exact-protection keys +35. Settings reset restoring complete defaults + +The runner also fails on uncaught page exceptions and extension-page/service-worker console errors. + +## Firefox current-artifact evidence + +The current Firefox artifact: + +- builds with the Firefox background-script manifest; +- installs in Firefox 151 headless; +- passed the seven strengthened runtime smoke assertions: fixture seeding, manager launch/listing, grouping, filtering, host-cookie selection, protection activation, and protected-cookie survival after page deletion; +- preserved `partitionKey.topLevelSite` and `hasCrossSiteAncestor` in targeted Firefox 151 set/query probes; +- handled exact nameless and host/domain tombstone deletion in targeted Firefox 151 probes. + +The committed RDP probe was hardened so fixture, selection, protection, and survival checks fail closed. Its asynchronous protection step now polls synchronously rather than mistaking an RDP Promise grip for a result. + +This is useful current-artifact smoke evidence, but it is not a full Firefox equivalent of the 35-scenario Chromium suite. + +## Historical baseline + +The original 0.5rc2 Firefox artifact remains useful for reconstructing intent. The old Chromium suite had 14 passing checks, and the historical Firefox probe had seven. Fan-out review showed that those checks contained shortcuts and false negatives: they did not expose service-worker cold-start cleanup, malformed domain URLs, incomplete cookie identity, partition blindness, unsafe repeated imports, or native `cookies.remove()` over-deletion. + +Those historical results are retained as behavioral context, not release signoff for 0.6.0. + +## Remaining matrix limitations + +- The full 35-scenario suite is Chromium-only; current Firefox automation is a seven-check smoke plus targeted API probes. +- Firefox 140, Firefox Android 142, and real Android UI behavior were not run on this machine. +- Chromium and Firefox private/incognito enablement and container/store isolation do not yet have a full automated browser-policy matrix. +- The packaged privileged UI still contains legacy jQuery, Bootstrap 3, Moment, and context-menu libraries. Unused treeview/theme assets were removed, DOM sinks were hardened, and Firefox lint reports notices rather than errors, but dependency modernization remains worthwhile. + +## Conclusion + +Version 0.6.0 has substantially stronger evidence than the previous parity candidate for its core intent: exact cookie management, safe protection, faithful import/export, site-specific search, settings integrity, and popup LocalStorage actions. Chromium is covered by a broad reproducible release gate. Firefox has a valid, lint-clean, runtime-smoked artifact, with deeper cross-browser and private-store automation still explicitly open. diff --git a/qa/chromium-qa.mjs b/qa/chromium-qa.mjs index e5ab5b8..d9eb2c2 100644 --- a/qa/chromium-qa.mjs +++ b/qa/chromium-qa.mjs @@ -2,6 +2,7 @@ import path from 'node:path'; import process from 'node:process'; import fs from 'node:fs'; import os from 'node:os'; +import {spawn, spawnSync} from 'node:child_process'; import {fileURLToPath} from 'node:url'; import {chromium} from 'playwright'; @@ -10,7 +11,7 @@ const __dirname = path.dirname(__filename); const workspaceRoot = path.resolve(__dirname, '..'); const extensionPath = path.join(workspaceRoot, 'build'); const fixtureUrl = 'http://lvh.me:4173/'; -const importFixturePath = path.join(workspaceRoot, 'qa', 'tmp-import-cookie.json'); +const fixtureHealthUrl = 'http://127.0.0.1:4173/api/request-info'; function assert(condition, message) { if (!condition) @@ -46,6 +47,121 @@ async function waitForCookieValue(page, cookieName, timeoutMs = 3000) { return null; } +async function getExtensionCookies(extensionPage, details) { + return extensionPage.evaluate(async (query) => browser.cookies.getAll(query), details); +} + +async function waitForExtensionCookie(extensionPage, details, predicate, timeoutMs = 4000) { + const start = Date.now(); + while ((Date.now() - start) < timeoutMs) { + const cookies = await getExtensionCookies(extensionPage, details); + const match = cookies.find(predicate); + if (match) + return match; + await extensionPage.waitForTimeout(100); + } + return null; +} + +async function fixtureIsReady() { + try { + const response = await fetch(fixtureHealthUrl); + return response.ok; + } catch (error) { + return false; + } +} + +async function ensureFixtureServer() { + if (await fixtureIsReady()) + return null; + + const certPath = path.join(workspaceRoot, 'qa', 'certs', 'fixture-cert.pem'); + const keyPath = path.join(workspaceRoot, 'qa', 'certs', 'fixture-key.pem'); + if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) { + const generated = spawnSync('bash', [path.join(workspaceRoot, 'qa', 'generate-dev-cert.sh')], { + cwd: workspaceRoot, + stdio: 'inherit', + }); + if (generated.status !== 0) + throw new Error('Unable to generate the QA fixture certificate.'); + } + + const child = spawn(process.execPath, [path.join(workspaceRoot, 'qa', 'fixture-server.mjs')], { + cwd: workspaceRoot, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let diagnostics = ''; + child.stdout.on('data', (chunk) => { diagnostics += chunk; }); + child.stderr.on('data', (chunk) => { diagnostics += chunk; }); + for (let attempt = 0; attempt < 100; attempt++) { + if (await fixtureIsReady()) + return child; + if (child.exitCode !== null) + throw new Error(`Fixture server exited early: ${diagnostics.trim()}`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + child.kill('SIGTERM'); + throw new Error(`Fixture server did not become ready: ${diagnostics.trim()}`); +} + +async function stopFixtureServer(child) { + if (!child || child.exitCode !== null) + return; + child.kill('SIGTERM'); + await Promise.race([ + new Promise((resolve) => child.once('exit', resolve)), + new Promise((resolve) => setTimeout(resolve, 2000)), + ]); + if (child.exitCode === null) + child.kill('SIGKILL'); +} + +async function closeModal(page, selector) { + const modal = page.locator(selector); + await modal.waitFor({state: 'visible'}); + await page.evaluate((modalSelector) => { + window.__cqmModalHidden = new Promise((resolve) => { + window.jQuery(modalSelector).one('hidden.bs.modal', resolve); + }); + }, selector); + await modal.locator('.btn').last().click(); + await page.evaluate(() => window.__cqmModalHidden); + await modal.waitFor({state: 'hidden'}); +} + +async function closeModalIfVisible(page, selector) { + try { + await page.locator(selector).waitFor({state: 'visible', timeout: 1500}); + } catch (error) { + return; + } + await closeModal(page, selector); +} + +async function waitForSelectedProtection(page, expected) { + await page.waitForFunction(async (isExpected) => { + const cookie = window.jQuery('#cookie-list li.active').data('cookie'); + if (!cookie) + return false; + const protectedCookies = await window.vAPI.get_protected_cookies(); + return window.CQMCore.isCookieProtected(cookie, protectedCookies) === isExpected; + }, expected); + await page.locator('#protect_button').waitFor({state: 'visible'}); + await page.waitForFunction(() => !document.querySelector('#protect_button').disabled); +} + +async function submitImport(page, file) { + const marker = `qa-import-pending-${Date.now()}-${Math.random()}`; + await page.evaluate((value) => { + document.getElementById('info_text').textContent = value; + }, marker); + await page.locator('#file_elem').setInputFiles(file); + await page.waitForFunction((value) => + document.getElementById('info_text').textContent !== value, marker); + return page.locator('#info_text').textContent(); +} + async function clickDomainAndCookie(managerPage, domainText, cookieName) { const clickedDomain = await managerPage.evaluate((targetDomainText) => { const items = [...document.querySelectorAll('#domain-list li')]; @@ -76,21 +192,43 @@ async function clickDomainAndCookie(managerPage, domainText, cookieName) { } async function run() { - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cqm-playwright-')); - const context = await chromium.launchPersistentContext(userDataDir, { - headless: true, - channel: 'chromium', - ignoreHTTPSErrors: true, - args: [ - `--disable-extensions-except=${extensionPath}`, - `--load-extension=${extensionPath}`, - ], - }); - - const extensionId = await getExtensionId(context); const results = []; - + let fixtureProcess = null; + let userDataDir = null; + let context = null; try { + fixtureProcess = await ensureFixtureServer(); + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cqm-playwright-')); + context = await chromium.launchPersistentContext(userDataDir, { + headless: true, + channel: 'chromium', + ignoreHTTPSErrors: true, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + '--host-resolver-rules=MAP lvh.me 127.0.0.1, MAP *.lvh.me 127.0.0.1', + ], + }); + + const extensionId = await getExtensionId(context); + const pageErrors = []; + const runtimeErrors = []; + const attachServiceWorkerDiagnostics = (worker) => { + worker.on('console', (message) => { + if (message.type() === 'error') + runtimeErrors.push(`service worker: ${message.text()}`); + }); + }; + context.serviceWorkers().forEach(attachServiceWorkerDiagnostics); + context.on('serviceworker', attachServiceWorkerDiagnostics); + context.on('page', (page) => { + page.on('pageerror', (error) => pageErrors.push(`${page.url()}: ${error.message}`)); + page.on('console', (message) => { + if (message.type() === 'error' && page.url().startsWith(`chrome-extension://${extensionId}/`)) + runtimeErrors.push(`${page.url()}: ${message.text()}`); + }); + }); + const fixturePage = await context.newPage(); await fixturePage.goto(fixtureUrl, {waitUntil: 'domcontentloaded'}); @@ -139,11 +277,13 @@ async function run() { status: 'passed', details: autoRefreshCookies, }); - await managerPage.locator('#auto_actualize_checkbox').click(); await fixturePage.evaluate(() => { document.cookie = 'fixture_auto_refresh=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT'; }); - await managerPage.waitForTimeout(500); + await managerPage.waitForFunction(() => + ![...document.querySelectorAll('#cookie-list li')].some((node) => + node.textContent.includes('fixture_auto_refresh'))); + await managerPage.locator('#auto_actualize_checkbox').click(); await managerPage.locator('#query-subdomains').check(); await managerPage.locator('#actualize_button').click(); @@ -184,6 +324,7 @@ async function run() { await clickDomainAndCookie(managerPage, 'lvh.me', 'fixture_js_host'); await managerPage.locator('#protect_button').click(); + await waitForSelectedProtection(managerPage, true); await fixturePage.bringToFront(); await fixturePage.getByRole('button', {name: 'Delete host cookie via JS'}).click(); @@ -216,48 +357,18 @@ async function run() { await managerPage.locator('#modal_clipboard').waitFor(); const exportedCookieDump = await managerPage.locator('#clipboard_textarea').inputValue(); assert(exportedCookieDump.includes('fixture_js_host'), 'Single-cookie export did not include the selected fixture cookie.'); - fs.writeFileSync(importFixturePath, exportedCookieDump, 'utf8'); - await managerPage.locator('#modal_clipboard .btn').last().click(); + assert(/1/.test(await managerPage.locator('#modal_clipboard h4.modal-title').textContent()), + 'Clipboard export title omitted its cookie count.'); + await closeModal(managerPage, '#modal_clipboard'); results.push({ check: 'export-cookie-json', status: 'passed', details: exportedCookieDump.slice(0, 120), }); - const directImportAttempt = await managerPage.evaluate(async (jsonDump) => { - const [jsonCookie] = JSON.parse(jsonDump); - const params = { - url: jsonCookie["Host raw"], - name: jsonCookie["Name raw"], - value: jsonCookie["Content raw"], - path: jsonCookie["Path raw"], - httpOnly: (jsonCookie["HTTP only raw"] === 'true'), - secure: (jsonCookie["Send for raw"] === 'true'), - storeId: jsonCookie["Store raw"], - }; - - if (jsonCookie["SameSite raw"] !== undefined) - params.sameSite = jsonCookie["SameSite raw"]; - - if (jsonCookie["Expires raw"] != "0") - params.expirationDate = parseInt(jsonCookie["Expires raw"], 10); - - try { - const cookie = await browser.cookies.set(params); - return {ok: true, params, cookie}; - } catch (error) { - return {ok: false, params, message: error.message}; - } - }, exportedCookieDump); - assert(directImportAttempt.ok, `Direct cookie restore failed with params ${JSON.stringify(directImportAttempt.params)}: ${directImportAttempt.message}`); - results.push({ - check: 'direct-json-restore', - status: 'passed', - details: directImportAttempt.params, - }); - await clickDomainAndCookie(managerPage, 'lvh.me', 'fixture_js_host'); await managerPage.locator('#protect_button').click(); + await waitForSelectedProtection(managerPage, false); await managerPage.locator('#delete_button').click(); await fixturePage.waitForTimeout(500); const deletedValue = await waitForCookieValue(fixturePage, 'fixture_js_host', 1000); @@ -269,7 +380,7 @@ async function run() { }); await managerPage.locator('.dropup-custom .dropdown-toggle').nth(1).click(); - await managerPage.locator('#file_elem').setInputFiles({ + await submitImport(managerPage, { name: 'fixture-cookie.json', mimeType: 'application/json', buffer: Buffer.from(exportedCookieDump, 'utf8'), @@ -285,6 +396,604 @@ async function run() { details: importedValue, }); + await closeModalIfVisible(managerPage, '#modal_info'); + + // Domain-scoped cookies were the largest blind spot in the original + // parity suite. Exercise edit, exact protection, and attribute restore. + await managerPage.locator('#search_domain').fill('lvh.me'); + await managerPage.locator('#actualize_button').click(); + await managerPage.waitForTimeout(300); + await clickDomainAndCookie(managerPage, '.lvh.me', 'fixture_js_domain'); + await managerPage.locator('#edit_button').click(); + await managerPage.locator('#value').fill('domain-edited-by-qa'); + await managerPage.locator('#save_button').click(); + const editedDomainCookie = await waitForExtensionCookie( + managerPage, + {name: 'fixture_js_domain'}, + (cookie) => cookie.value === 'domain-edited-by-qa', + ); + assert(editedDomainCookie?.domain === '.lvh.me' && editedDomainCookie.hostOnly === false, + `Domain-cookie edit lost scope or failed: ${JSON.stringify(editedDomainCookie)}`); + results.push({ + check: 'edit-domain-cookie', + status: 'passed', + details: {domain: editedDomainCookie.domain, hostOnly: editedDomainCookie.hostOnly}, + }); + + const protectedDomainCookie = await managerPage.evaluate(async () => browser.cookies.set({ + url: 'https://lvh.me/', + domain: 'lvh.me', + name: 'fixture_protected_domain', + value: 'protected-domain-value', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'strict', + expirationDate: Math.floor(Date.now() / 1000) + 3600, + storeId: '0', + })); + assert(protectedDomainCookie, 'Unable to seed protected domain cookie.'); + await managerPage.evaluate(async (cookie) => vAPI.set_cookie_protection([cookie], true), protectedDomainCookie); + await managerPage.evaluate(async (cookie) => browser.cookies.remove({ + url: 'https://lvh.me/', + name: cookie.name, + storeId: cookie.storeId, + }), protectedDomainCookie); + const restoredDomainCookie = await waitForExtensionCookie( + managerPage, + {name: protectedDomainCookie.name}, + (cookie) => cookie.value === protectedDomainCookie.value, + ); + assert(restoredDomainCookie, 'Protected domain cookie was not restored.'); + for (const field of ['domain', 'hostOnly', 'path', 'name', 'value', 'secure', 'httpOnly', 'sameSite', 'session', 'storeId']) + assert(restoredDomainCookie[field] === protectedDomainCookie[field], + `Protected domain cookie changed ${field}: ${protectedDomainCookie[field]} -> ${restoredDomainCookie[field]}`); + results.push({ + check: 'protected-domain-attribute-restore', + status: 'passed', + details: { + domain: restoredDomainCookie.domain, + sameSite: restoredDomainCookie.sameSite, + httpOnly: restoredDomainCookie.httpOnly, + }, + }); + await managerPage.evaluate(async (cookie) => { + await vAPI.set_cookie_protection([cookie], false); + await browser.cookies.remove({url: 'https://lvh.me/', name: cookie.name, storeId: cookie.storeId}); + }, restoredDomainCookie); + + const rotatingCookie = await managerPage.evaluate(async () => browser.cookies.set({ + url: 'http://lvh.me/', + name: 'fixture_protected_rotation', + value: 'old', + path: '/', + secure: false, + httpOnly: false, + sameSite: 'strict', + storeId: '0', + })); + await managerPage.evaluate(async (cookie) => { + await vAPI.set_cookie_protection([cookie], true); + await browser.cookies.remove({url: 'http://lvh.me/', name: cookie.name, storeId: cookie.storeId}); + await browser.cookies.set(CQMCore.buildCookieSetDetails({...cookie, value: 'fresh'})); + }, rotatingCookie); + await managerPage.waitForTimeout(400); + const rotatedCookies = await getExtensionCookies(managerPage, {name: rotatingCookie.name}); + assert(rotatedCookies.length === 1 && rotatedCookies[0].value === 'fresh', + `Stale protection restore overwrote cookie rotation: ${JSON.stringify(rotatedCookies)}`); + results.push({check: 'protected-cookie-rotation', status: 'passed', details: 'fresh value retained'}); + await managerPage.evaluate(async (cookie) => { + await vAPI.set_cookie_protection([cookie], false); + await browser.cookies.remove({url: 'http://lvh.me/', name: cookie.name, storeId: cookie.storeId}); + }, rotatedCookies[0]); + + const pathCookies = await managerPage.evaluate(async () => Promise.all([ + browser.cookies.set({url: 'http://lvh.me/', name: 'fixture_path_identity', value: 'root', path: '/', storeId: '0'}), + browser.cookies.set({url: 'http://lvh.me/app', name: 'fixture_path_identity', value: 'app', path: '/app', storeId: '0'}), + ])); + const pathDeletionResult = await managerPage.evaluate(async (cookies) => { + const rootCookie = cookies.find((cookie) => cookie.path === '/'); + await vAPI.set_cookie_protection([rootCookie], true); + const protectedCount = await vAPI.delete_cookies(Promise.resolve(cookies)); + await new Promise((resolve) => setTimeout(resolve, 250)); + return { + protectedCount, + remaining: await browser.cookies.getAll({name: rootCookie.name}), + }; + }, pathCookies); + assert(pathDeletionResult.protectedCount === 1 && pathDeletionResult.remaining.length === 1 && + pathDeletionResult.remaining[0].path === '/', + `Exact path protection failed: ${JSON.stringify(pathDeletionResult)}`); + results.push({ + check: 'exact-path-protection', + status: 'passed', + details: {protectedCount: pathDeletionResult.protectedCount, remainingPath: pathDeletionResult.remaining[0].path}, + }); + await managerPage.evaluate(async (cookie) => { + await vAPI.set_cookie_protection([cookie], false); + await vAPI.remove_cookie(cookie); + }, pathDeletionResult.remaining[0]); + + const unprotectedPathCookies = await managerPage.evaluate(async () => Promise.all([ + vAPI.set_cookie({url: 'http://lvh.me/', name: 'fixture_exact_path_delete', value: 'root', path: '/', storeId: '0'}), + vAPI.set_cookie({url: 'http://lvh.me/', name: 'fixture_exact_path_delete', value: 'app', path: '/app', storeId: '0'}), + vAPI.set_cookie({url: 'http://lvh.me/', name: 'fixture_exact_path_delete', value: 'odd', path: '/literal?#', storeId: '0'}), + ])); + assert(unprotectedPathCookies.every(Boolean), + `Exact set-result resolution failed for a path sibling: ${JSON.stringify(unprotectedPathCookies)}`); + await managerPage.evaluate(async (cookie) => vAPI.delete_cookies(Promise.resolve([cookie])), + unprotectedPathCookies.find((cookie) => cookie.path === '/app')); + let exactPathSurvivors = await getExtensionCookies(managerPage, {name: 'fixture_exact_path_delete'}); + const oddPathCookie = exactPathSurvivors.find((cookie) => cookie.value === 'odd'); + assert(exactPathSurvivors.length === 2 && exactPathSurvivors.some((cookie) => cookie.path === '/') && oddPathCookie, + `Deleting /app damaged a same-name path sibling: ${JSON.stringify(exactPathSurvivors)}`); + await managerPage.evaluate(async (cookie) => vAPI.delete_cookies(Promise.resolve([cookie])), oddPathCookie); + exactPathSurvivors = await getExtensionCookies(managerPage, {name: 'fixture_exact_path_delete'}); + assert(exactPathSurvivors.length === 1 && exactPathSurvivors[0].path === '/', + `Deleting an unusual path damaged the root sibling: ${JSON.stringify(exactPathSurvivors)}`); + await managerPage.evaluate(async (cookies) => vAPI.delete_cookies(Promise.resolve(cookies)), exactPathSurvivors); + results.push({ + check: 'exact-unprotected-path-deletion', + status: 'passed', + details: 'root, /app, and /literal?# remained independently addressable', + }); + + const collisionPage = await context.newPage(); + await collisionPage.goto('http://sub.lvh.me:4173/', {waitUntil: 'domcontentloaded'}); + const collisionCookies = await managerPage.evaluate(async () => { + const name = 'fixture_host_domain_collision'; + await browser.cookies.set({ + url: 'http://sub.lvh.me/', domain: 'lvh.me', name, value: 'domain', path: '/', storeId: '0', + }); + await browser.cookies.set({ + url: 'http://sub.lvh.me/', name, value: 'host', path: '/', storeId: '0', + }); + return browser.cookies.getAll({name, storeId: '0'}); + }); + const collisionHost = collisionCookies.find((cookie) => cookie.hostOnly && cookie.domain === 'sub.lvh.me'); + const collisionDomain = collisionCookies.find((cookie) => !cookie.hostOnly && cookie.domain === '.lvh.me'); + assert(collisionHost && collisionDomain, `Unable to create host/domain collision: ${JSON.stringify(collisionCookies)}`); + await managerPage.evaluate(async (cookie) => vAPI.set_cookie_protection([cookie], true), collisionHost); + await collisionPage.evaluate((name) => { + document.cookie = `${name}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT`; + }, collisionHost.name); + const restoredCollisionHost = await waitForExtensionCookie( + managerPage, + {name: collisionHost.name, storeId: '0'}, + (cookie) => cookie.hostOnly && cookie.domain === 'sub.lvh.me' && cookie.value === 'host', + ); + const afterCollisionRestore = await getExtensionCookies(managerPage, {name: collisionHost.name, storeId: '0'}); + assert(restoredCollisionHost && afterCollisionRestore.some((cookie) => cookie.domain === '.lvh.me'), + `Host restore confused a surviving domain sibling: ${JSON.stringify(afterCollisionRestore)}`); + await managerPage.evaluate(async (cookie) => { + await vAPI.set_cookie_protection([cookie], false); + await vAPI.delete_cookies(Promise.resolve([cookie])); + }, restoredCollisionHost); + const afterExactHostDelete = await getExtensionCookies(managerPage, {name: collisionHost.name, storeId: '0'}); + assert(afterExactHostDelete.length === 1 && afterExactHostDelete[0].domain === '.lvh.me', + `Exact host deletion damaged its domain sibling: ${JSON.stringify(afterExactHostDelete)}`); + await managerPage.evaluate(async (cookies) => vAPI.delete_cookies(Promise.resolve(cookies)), afterExactHostDelete); + await collisionPage.close(); + results.push({ + check: 'host-domain-collision-restore-delete', + status: 'passed', + details: 'host-only and domain identities stayed independent', + }); + + const specialValue = 'quote-" backslash-\\ token-{EXPIRES} unicode-雪'; + await managerPage.evaluate(async ({value}) => browser.cookies.set({ + url: 'http://lvh.me/', + domain: 'lvh.me', + name: 'fixture_json_domain_special', + value, + path: '/', + sameSite: 'lax', + storeId: '0', + }), {value: specialValue}); + await managerPage.locator('#actualize_button').click(); + await managerPage.waitForTimeout(300); + await clickDomainAndCookie(managerPage, '.lvh.me', 'fixture_json_domain_special'); + + // Delete in a focused editor must edit text, never delete the cookie. + await managerPage.locator('#value').focus(); + await managerPage.keyboard.press('Delete'); + assert((await getExtensionCookies(managerPage, {name: 'fixture_json_domain_special'})).length === 1, + 'Delete key in the value editor deleted the selected cookie.'); + results.push({check: 'editor-delete-key-safety', status: 'passed', details: 'cookie retained'}); + + await managerPage.evaluate(async () => Promise.all([ + browser.cookies.set({url: 'http://lvh.me/', name: 'fixture_visible_delete_target', value: 'delete', path: '/'}), + browser.cookies.set({url: 'http://lvh.me/', name: 'fixture_visible_delete_survivor', value: 'keep', path: '/'}), + ])); + await managerPage.locator('#search_domain').fill('lvh.me :name:"fixture_visible_delete_target"'); + await managerPage.locator('#actualize_button').click(); + await managerPage.waitForTimeout(300); + assert((await managerPage.locator('#cookie-list li').count()) === 1, + 'Visible-delete fixture did not isolate one search result.'); + await managerPage.locator('#ask_total_deletion_button').click(); + await managerPage.locator('#modal_alert').waitFor({state: 'visible'}); + await managerPage.locator('#delete_all_button').click(); + await managerPage.waitForTimeout(300); + assert((await getExtensionCookies(managerPage, {name: 'fixture_visible_delete_target'})).length === 0, + 'Visible target cookie was not deleted.'); + assert((await getExtensionCookies(managerPage, {name: 'fixture_visible_delete_survivor'})).length === 1, + 'Filtered bulk deletion removed a non-visible cookie.'); + results.push({check: 'delete-visible-filtered-only', status: 'passed', details: 'unrelated cookie survived'}); + await managerPage.evaluate(async () => browser.cookies.remove({ + url: 'http://lvh.me/', name: 'fixture_visible_delete_survivor', + })); + await managerPage.locator('#search_domain').fill('lvh.me'); + await managerPage.locator('#actualize_button').click(); + await managerPage.waitForTimeout(300); + await clickDomainAndCookie(managerPage, '.lvh.me', 'fixture_json_domain_special'); + + await managerPage.locator('.dropup-custom .dropdown-toggle').nth(1).click(); + await managerPage.locator('#clipboard_cookie_export').click(); + const domainExport = await managerPage.locator('#clipboard_textarea').inputValue(); + const [domainExportRecord] = JSON.parse(domainExport); + assert(domainExportRecord['Host raw'] === 'http://lvh.me/', + `Domain export contains malformed URL: ${domainExportRecord['Host raw']}`); + assert(domainExportRecord['Content raw'] === specialValue, + 'Structured JSON export corrupted special characters or template tokens.'); + await closeModal(managerPage, '#modal_clipboard'); + const specialCookieBeforeImport = (await getExtensionCookies( + managerPage, {name: 'fixture_json_domain_special'}))[0]; + await managerPage.evaluate(async (cookie) => vAPI.delete_cookies(Promise.resolve([cookie])), specialCookieBeforeImport); + assert((await getExtensionCookies(managerPage, {name: 'fixture_json_domain_special'})).length === 0, + 'Domain JSON fixture was not removed before import.'); + await submitImport(managerPage, { + name: 'domain-special.json', + mimeType: '', + buffer: Buffer.from(domainExport, 'utf8'), + }); + const restoredSpecialCookie = await waitForExtensionCookie( + managerPage, + {name: 'fixture_json_domain_special'}, + (cookie) => cookie.value === specialValue, + ); + assert(restoredSpecialCookie?.domain === '.lvh.me' && restoredSpecialCookie.hostOnly === false, + `Domain JSON round-trip failed: ${JSON.stringify(restoredSpecialCookie)}`); + results.push({ + check: 'domain-json-roundtrip-special-characters', + status: 'passed', + details: {domain: restoredSpecialCookie.domain, value: restoredSpecialCookie.value}, + }); + await closeModalIfVisible(managerPage, '#modal_info'); + + const invalidRecord = {...domainExportRecord, 'Name raw': 'fixture_partial_import_must_not_exist'}; + const invalidImportInfo = await submitImport(managerPage, { + name: 'invalid-partial.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify([invalidRecord, null]), 'utf8'), + }); + assert(invalidImportInfo.length > 0, 'Invalid import did not report an error.'); + assert((await getExtensionCookies(managerPage, {name: invalidRecord['Name raw']})).length === 0, + 'Invalid partial import mutated cookies before full validation.'); + assert(!(await managerPage.locator('#info_text').innerHTML()).includes(' vAPI.delete_cookies(Promise.resolve([cookie])), restoredSpecialCookie); + + const namelessRecord = { + ...domainExportRecord, + 'Host raw': 'http://lvh.me/', + 'Name raw': '', + 'Content raw': 'nameless-value', + 'Expires raw': '0', + 'This domain only raw': 'true', + }; + const expiredRecord = { + ...namelessRecord, + 'Name raw': 'fixture_expired_import_skip', + 'Expires raw': '1', + }; + const agingImportInfo = await submitImport(managerPage, { + name: 'aging-backup.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify([namelessRecord, expiredRecord]), 'utf8'), + }); + const namelessCookie = await waitForExtensionCookie( + managerPage, + {name: '', domain: 'lvh.me'}, + (cookie) => cookie.hostOnly && cookie.value === 'nameless-value', + ); + assert(namelessCookie && + (await getExtensionCookies(managerPage, {name: 'fixture_expired_import_skip'})).length === 0 && + /1\s+cookie/i.test(agingImportInfo) && /expired/i.test(agingImportInfo), + `Aging backup import mishandled live/expired records: ${JSON.stringify({namelessCookie, agingImportInfo})}`); + await managerPage.evaluate(async (cookie) => { + await vAPI.set_cookie_protection([cookie], true); + const protectedCookies = await vAPI.get_protected_cookies(); + if (!CQMCore.isCookieProtected(cookie, protectedCookies)) + throw new Error('Nameless cookie protection failed.'); + await vAPI.set_cookie_protection([cookie], false); + await vAPI.delete_cookies(Promise.resolve([cookie])); + }, namelessCookie); + await closeModalIfVisible(managerPage, '#modal_info'); + results.push({ + check: 'aging-backup-and-nameless-cookie', + status: 'passed', + details: agingImportInfo, + }); + + await managerPage.evaluate(async () => browser.storage.local.set({template: 'NETSCAPE'})); + await managerPage.reload({waitUntil: 'domcontentloaded'}); + await managerPage.locator('#domain-list li').first().waitFor(); + const netscapeCookies = await managerPage.evaluate(async () => Promise.all([ + vAPI.set_cookie({ + url: 'https://lvh.me/', + name: 'fixture_netscape_host', + value: 'host-value', + path: '/', + secure: true, + httpOnly: true, + expirationDate: Math.floor(Date.now() / 1000) + 3600, + }), + vAPI.set_cookie({ + url: 'http://lvh.me/', + domain: 'lvh.me', + name: 'fixture_netscape_domain', + value: 'domain-value', + path: '/', + secure: false, + httpOnly: false, + expirationDate: Math.floor(Date.now() / 1000) + 3600, + }), + ])); + assert(netscapeCookies.every(Boolean), `Unable to seed Netscape fixtures: ${JSON.stringify(netscapeCookies)}`); + await managerPage.locator('#search_domain').fill('lvh.me'); + await managerPage.locator('#actualize_button').click(); + await managerPage.waitForTimeout(300); + await clickDomainAndCookie(managerPage, 'lvh.me', 'fixture_netscape_host'); + await managerPage.locator('.dropup-custom .dropdown-toggle').nth(1).click(); + await managerPage.locator('#clipboard_cookie_export').click(); + await managerPage.locator('#modal_clipboard').waitFor({state: 'visible'}); + const netscapeHostLine = await managerPage.locator('#clipboard_textarea').inputValue(); + await closeModal(managerPage, '#modal_clipboard'); + const hostFields = netscapeHostLine.replace(/^#HttpOnly_/, '').split('\t'); + assert(netscapeHostLine.startsWith('#HttpOnly_lvh.me\t') && hostFields[1] === 'false' && hostFields[3] === 'true', + `Host-only/HttpOnly Netscape export is nonstandard: ${netscapeHostLine}`); + + await clickDomainAndCookie(managerPage, '.lvh.me', 'fixture_netscape_domain'); + await managerPage.locator('.dropup-custom .dropdown-toggle').nth(1).click(); + await managerPage.locator('#clipboard_cookie_export').click(); + await managerPage.locator('#modal_clipboard').waitFor({state: 'visible'}); + const netscapeDomainLine = await managerPage.locator('#clipboard_textarea').inputValue(); + await closeModal(managerPage, '#modal_clipboard'); + const domainFields = netscapeDomainLine.split('\t'); + assert(domainFields[0] === '.lvh.me' && domainFields[1] === 'true', + `Domain Netscape export lost include-subdomains semantics: ${netscapeDomainLine}`); + await managerPage.evaluate(async (cookies) => vAPI.delete_cookies(Promise.resolve(cookies)), netscapeCookies); + assert((await getExtensionCookies(managerPage, {name: 'fixture_netscape_host'})).length === 0 && + (await getExtensionCookies(managerPage, {name: 'fixture_netscape_domain'})).length === 0, + 'Netscape fixtures were not removed before import.'); + + const uppercaseNetscape = [netscapeHostLine, netscapeDomainLine].map((line) => { + const httpOnly = line.startsWith('#HttpOnly_'); + const fields = (httpOnly ? line.slice('#HttpOnly_'.length) : line).split('\t'); + fields[1] = fields[1].toUpperCase(); + fields[3] = fields[3].toUpperCase(); + return `${httpOnly ? '#HttpOnly_' : ''}${fields.join('\t')}`; + }).join('\n'); + await submitImport(managerPage, { + name: 'cookies.txt', + mimeType: 'text/plain', + buffer: Buffer.from(`\uFEFF# Netscape HTTP Cookie File\n${uppercaseNetscape}`, 'utf8'), + }); + const restoredNetscapeHost = await waitForExtensionCookie( + managerPage, + {name: 'fixture_netscape_host'}, + (cookie) => cookie.hostOnly && cookie.httpOnly && cookie.secure, + ); + const restoredNetscapeDomain = await waitForExtensionCookie( + managerPage, + {name: 'fixture_netscape_domain'}, + (cookie) => !cookie.hostOnly && cookie.domain === '.lvh.me', + ); + assert(restoredNetscapeHost && restoredNetscapeDomain, + `Canonical uppercase/BOM Netscape import failed: ${JSON.stringify({restoredNetscapeHost, restoredNetscapeDomain})}`); + await closeModalIfVisible(managerPage, '#modal_info'); + await managerPage.evaluate(async (cookies) => { + await vAPI.delete_cookies(Promise.resolve(cookies)); + await browser.storage.local.set({template: 'JSON'}); + }, [restoredNetscapeHost, restoredNetscapeDomain]); + await managerPage.reload({waitUntil: 'domcontentloaded'}); + await managerPage.locator('#domain-list li').first().waitFor(); + results.push({ + check: 'netscape-standard-roundtrip', + status: 'passed', + details: 'host/domain, HttpOnly, Secure, uppercase flags, BOM, and default store', + }); + + const partitionedCookie = await managerPage.evaluate(async () => browser.cookies.set({ + url: 'https://lvh.me/', + name: 'fixture_partitioned', + value: 'chips', + path: '/', + secure: true, + sameSite: 'no_restriction', + storeId: '0', + partitionKey: {topLevelSite: 'https://example.test'}, + })); + assert(partitionedCookie?.partitionKey, 'Unable to create partitioned fixture cookie.'); + const partitionedListing = await managerPage.evaluate(async () => vAPI.get_cookies({name: 'fixture_partitioned'})); + assert(partitionedListing.length === 1 && partitionedListing[0].partitionKey?.topLevelSite, + `Partitioned cookie is invisible: ${JSON.stringify(partitionedListing)}`); + await managerPage.locator('#search_domain').fill('lvh.me'); + await managerPage.locator('#query-subdomains').uncheck(); + await managerPage.locator('#actualize_button').click(); + await managerPage.waitForTimeout(300); + await clickDomainAndCookie(managerPage, 'lvh.me', 'fixture_partitioned'); + assert(await managerPage.locator('#partition-key-row').isVisible() && + (await managerPage.locator('#partition-key').inputValue()).startsWith('https://example.test'), + 'Partition scope is not visible in the cookie details UI.'); + await managerPage.locator('#edit_button').click(); + await managerPage.locator('#value').fill('chips-edited'); + await managerPage.locator('#save_button').click(); + const editedPartitionedCookie = await waitForExtensionCookie( + managerPage, + {name: 'fixture_partitioned', partitionKey: {}}, + (cookie) => cookie.value === 'chips-edited', + ); + assert(editedPartitionedCookie?.partitionKey?.topLevelSite === 'https://example.test', + `Partitioned-cookie edit lost its partition: ${JSON.stringify(editedPartitionedCookie)}`); + assert((await getExtensionCookies(managerPage, {name: 'fixture_partitioned'})).length === 0, + 'Partitioned-cookie edit created an unpartitioned duplicate.'); + + await managerPage.locator('.dropup-custom .dropdown-toggle').nth(1).click(); + await managerPage.locator('#clipboard_cookie_export').click(); + const partitionedExport = await managerPage.locator('#clipboard_textarea').inputValue(); + const [partitionedExportRecord] = JSON.parse(partitionedExport); + assert(partitionedExportRecord['Partition key']?.topLevelSite === 'https://example.test', + `Single-cookie export lost partition identity: ${partitionedExport}`); + await closeModal(managerPage, '#modal_clipboard'); + await managerPage.evaluate(async (cookies) => vAPI.delete_cookies(Promise.resolve(cookies)), [editedPartitionedCookie]); + assert((await managerPage.evaluate(async () => + browser.cookies.getAll({name: 'fixture_partitioned', partitionKey: {}}))).length === 0, + 'Partitioned fixture was not removed before import.'); + await submitImport(managerPage, { + name: 'partitioned-cookie.json', + mimeType: 'application/json', + buffer: Buffer.from(partitionedExport, 'utf8'), + }); + const restoredPartitionedCookie = await waitForExtensionCookie( + managerPage, + {name: 'fixture_partitioned', partitionKey: {}}, + (cookie) => cookie.value === 'chips-edited', + ); + assert(restoredPartitionedCookie?.partitionKey?.topLevelSite === 'https://example.test', + `Partitioned-cookie import lost scope: ${JSON.stringify(restoredPartitionedCookie)}`); + await closeModalIfVisible(managerPage, '#modal_info'); + await managerPage.evaluate(async (cookies) => vAPI.delete_cookies(Promise.resolve(cookies)), [restoredPartitionedCookie]); + const partitionedAfterDelete = await managerPage.evaluate(async () => + browser.cookies.getAll({name: 'fixture_partitioned', partitionKey: {}})); + assert(partitionedAfterDelete.length === 0, 'Partitioned cookie deletion failed.'); + results.push({ + check: 'partitioned-cookie-edit-export-import-delete', + status: 'passed', + details: partitionedCookie.partitionKey, + }); + + await managerPage.evaluate(async () => Promise.all([ + browser.cookies.set({ + url: 'https://lvh.me/', name: 'fixture_partition_visual', value: 'one', path: '/', secure: true, + sameSite: 'no_restriction', storeId: '0', + partitionKey: {topLevelSite: 'https://lvh.me', hasCrossSiteAncestor: false}, + }), + browser.cookies.set({ + url: 'https://lvh.me/', name: 'fixture_partition_visual', value: 'two', path: '/', secure: true, + sameSite: 'no_restriction', storeId: '0', + partitionKey: {topLevelSite: 'https://lvh.me', hasCrossSiteAncestor: true}, + }), + ])); + const partitionVisualCookies = await managerPage.evaluate(async () => + vAPI.get_cookies({name: 'fixture_partition_visual'})); + assert(partitionVisualCookies.length === 2, + `Unable to create two partition-identity fixtures: ${JSON.stringify(partitionVisualCookies)}`); + await managerPage.locator('#search_domain').fill('lvh.me'); + await managerPage.locator('#query-subdomains').uncheck(); + await managerPage.locator('#actualize_button').click(); + await managerPage.waitForTimeout(300); + await clickDomainAndCookie(managerPage, 'lvh.me', 'fixture_partition_visual'); + const partitionLabels = await managerPage.locator('#cookie-list li').evaluateAll((nodes) => nodes + .filter((node) => node.textContent.includes('fixture_partition_visual')) + .map((node) => node.querySelector('.partition-badge')?.textContent)); + assert(partitionLabels.length === 2 && partitionLabels.some((label) => label.includes('hasCrossSiteAncestor=false')) && + partitionLabels.some((label) => label.includes('hasCrossSiteAncestor=true')), + `Partitioned cookie rows are visually ambiguous: ${JSON.stringify(partitionLabels)}`); + await managerPage.evaluate(async () => { + const cookies = await vAPI.get_cookies({name: 'fixture_partition_visual'}); + await vAPI.delete_cookies(Promise.resolve(cookies)); + }); + results.push({check: 'partition-scope-visible', status: 'passed', details: partitionLabels}); + + const subdomainPage = await context.newPage(); + await subdomainPage.goto('http://sub.lvh.me:4173/', {waitUntil: 'domcontentloaded'}); + await subdomainPage.getByRole('button', {name: 'Set host-only JS cookie'}).click(); + await managerPage.locator('#search_domain').fill('lvh.me'); + await managerPage.locator('#query-subdomains').check(); + await managerPage.locator('#actualize_button').click(); + await managerPage.waitForTimeout(350); + const actualSubdomainGrouping = await managerPage.evaluate(() => + [...document.querySelectorAll('#domain-list li')].map((node) => + node.childNodes[0]?.textContent?.trim() || node.textContent.trim())); + assert(actualSubdomainGrouping.length === 1 && actualSubdomainGrouping[0] === 'lvh.me', + `Actual subdomain cookie was not grouped at a label boundary: ${JSON.stringify(actualSubdomainGrouping)}`); + results.push({check: 'actual-subdomain-grouping', status: 'passed', details: actualSubdomainGrouping}); + + const siteManagerPage = await context.newPage(); + await siteManagerPage.goto( + `chrome-extension://${extensionId}/cookies.html?parent_url=${encodeURIComponent('http://sub.lvh.me:4173/')}`, + {waitUntil: 'domcontentloaded'}, + ); + await siteManagerPage.locator('#domain-list li').first().waitFor(); + const siteSpecificDomains = await siteManagerPage.evaluate(() => + [...document.querySelectorAll('#domain-list li')].map((node) => + node.childNodes[0]?.textContent?.trim() || node.textContent.trim())); + assert(siteSpecificDomains.includes('sub.lvh.me') && siteSpecificDomains.includes('.lvh.me') && + !siteSpecificDomains.includes('lvh.me'), + `Site-specific launch omitted applicable parent cookies or included host-only parents: ${JSON.stringify(siteSpecificDomains)}`); + results.push({check: 'site-specific-parent-domain-filter', status: 'passed', details: siteSpecificDomains}); + await siteManagerPage.close(); + await subdomainPage.close(); + + await managerPage.evaluate(async () => Promise.all([ + browser.cookies.set({url: 'http://example.com/', name: 'fixture_domain_boundary', value: 'parent', path: '/'}), + browser.cookies.set({url: 'http://notexample.com/', name: 'fixture_domain_boundary', value: 'other', path: '/'}), + ])); + await managerPage.locator('#search_domain').fill('example.com'); + await managerPage.locator('#actualize_button').click(); + await managerPage.waitForTimeout(350); + const boundaryDomains = await managerPage.evaluate(() => + [...document.querySelectorAll('#domain-list li')].map((node) => + node.childNodes[0]?.textContent?.trim() || node.textContent.trim())); + assert(boundaryDomains.includes('example.com') && boundaryDomains.includes('notexample.com'), + `Domain grouping merged substring-only hosts: ${JSON.stringify(boundaryDomains)}`); + results.push({check: 'domain-boundary-grouping', status: 'passed', details: boundaryDomains}); + await managerPage.evaluate(async () => Promise.all([ + browser.cookies.remove({url: 'http://example.com/', name: 'fixture_domain_boundary'}), + browser.cookies.remove({url: 'http://notexample.com/', name: 'fixture_domain_boundary'}), + ])); + + const prototypeCookie = await managerPage.evaluate(async () => browser.cookies.set({ + url: 'http://constructor/', name: 'fixture_prototype_domain', value: 'visible', path: '/', + })); + assert(prototypeCookie, 'Unable to seed prototype-named intranet domain.'); + await managerPage.locator('#query-subdomains').uncheck(); + await managerPage.locator('#search_domain').fill('constructor'); + await managerPage.locator('#actualize_button').click(); + await managerPage.waitForTimeout(350); + assert(await managerPage.locator('#cookie-list').getByText(/fixture_prototype_domain/).isVisible(), + 'Prototype-named domain disappeared from manager data structures.'); + results.push({check: 'prototype-domain-safety', status: 'passed', details: prototypeCookie.domain}); + await managerPage.evaluate(async () => browser.cookies.remove({ + url: 'http://constructor/', name: 'fixture_prototype_domain', + })); + + await fixturePage.bringToFront(); + const localStorageBefore = await fixturePage.evaluate(() => window.localStorage.length); + assert(localStorageBefore === 2, `Expected two fixture LocalStorage values, got ${localStorageBefore}.`); + const menuPage = await context.newPage(); + const menuNavigation = menuPage.goto(`chrome-extension://${extensionId}/menu.html`, {waitUntil: 'domcontentloaded'}); + await fixturePage.bringToFront(); + await menuNavigation; + await menuPage.waitForFunction(() => document.querySelector('#delete_current_localstorage').textContent.includes('(2)')); + const popupCounts = { + site: await menuPage.locator('#delete_current_cookies').textContent(), + store: await menuPage.locator('#delete_context_cookies').textContent(), + localStorage: await menuPage.locator('#delete_current_localstorage').textContent(), + }; + assert(/\(\d+\)/.test(popupCounts.site) && /\(\d+\)/.test(popupCounts.store), + `Popup cookie counts did not render: ${JSON.stringify(popupCounts)}`); + await menuPage.locator('#delete_current_localstorage').click(); + await fixturePage.waitForFunction(() => window.localStorage.length === 0); + assert((await fixturePage.evaluate(() => window.localStorage.length)) === 0, + 'Popup LocalStorage clear failed.'); + if (!menuPage.isClosed()) + await menuPage.close(); + results.push({check: 'popup-counts-localstorage-clear', status: 'passed', details: popupCounts}); + const secureFixturePage = await context.newPage(); await secureFixturePage.goto('https://lvh.me:4443/', {waitUntil: 'domcontentloaded'}); @@ -315,6 +1024,8 @@ async function run() { await optionsPage.goto(`chrome-extension://${extensionId}/options.html`, {waitUntil: 'domcontentloaded'}); const fpiVisible = await optionsPage.locator('#fpi_status').isVisible(); assert(!fpiVisible, 'First-Party Isolation control should be hidden on Chromium.'); + assert((await optionsPage.locator('#current-version').textContent()) === '0.6.0', + 'Options About page does not show the manifest release version.'); results.push({ check: 'options-hide-fpi', status: 'passed', @@ -335,6 +1046,71 @@ async function run() { }, }); + await optionsPage.evaluate(async () => browser.storage.local.set({ + protected_cookies: { + 'example.test': ['unsafe markup'], + }, + skin: 'javascript:alert(1)', + })); + await optionsPage.locator('#my-protected-cookies-toggle').click(); + await optionsPage.locator('#protected-cookie-tree label').waitFor(); + assert((await optionsPage.locator('#fixture-settings-injection').count()) === 0, + 'Protected-cookie settings were interpreted as privileged HTML.'); + assert((await optionsPage.locator('#protected-cookie-tree').textContent()).includes(''), + 'Protected-cookie name was not rendered literally.'); + results.push({check: 'settings-html-injection-safety', status: 'passed', details: 'markup rendered as text'}); + + await optionsPage.evaluate(async () => { + const staleCookie = { + domain: 'stale.example', hostOnly: true, path: '/', name: 'stale', storeId: '0', + }; + await browser.storage.local.set({ + [CQMCore.protectionStorageKey(staleCookie)]: { + domain: staleCookie.domain, + record: CQMCore.makeProtectionRecord(staleCookie), + }, + }); + }); + await optionsPage.locator('#restoreFilePicker').setInputFiles({ + name: 'settings-backup.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify({display_deletion_alert: false, template: 'NETSCAPE'}), 'utf8'), + }); + await optionsPage.waitForFunction(async () => { + const values = await browser.storage.local.get(null); + return values.display_deletion_alert === false && values.template === 'NETSCAPE' && + !Object.keys(values).some((key) => key.startsWith('protected_cookie:')); + }); + results.push({ + check: 'settings-restore-replaces-snapshot', + status: 'passed', + details: 'stale exact protection removed', + }); + + await optionsPage.locator('a[href="#settings"]').click(); + await optionsPage.locator('#resetUserDataButton').click(); + await optionsPage.waitForFunction(async () => { + const values = await browser.storage.local.get(null); + return values.display_deletion_alert === true && + values.prevent_protected_cookies_deletion === true && + values.template === 'JSON'; + }); + const resetSettings = await optionsPage.evaluate(async () => browser.storage.local.get(null)); + assert(!Object.keys(resetSettings).some((key) => key.startsWith('protected_cookie:')), + 'Settings reset left exact protection records behind.'); + results.push({ + check: 'settings-reset-defaults', + status: 'passed', + details: { + display_deletion_alert: resetSettings.display_deletion_alert, + prevent_protected_cookies_deletion: resetSettings.prevent_protected_cookies_deletion, + template: resetSettings.template, + }, + }); + + assert(pageErrors.length === 0, `Unexpected extension/page errors: ${JSON.stringify(pageErrors)}`); + assert(runtimeErrors.length === 0, `Unexpected extension runtime errors: ${JSON.stringify(runtimeErrors)}`); + console.log(JSON.stringify({ ok: true, extensionId, @@ -348,9 +1124,11 @@ async function run() { }, null, 2)); process.exitCode = 1; } finally { - fs.rmSync(importFixturePath, {force: true}); - await context.close(); - fs.rmSync(userDataDir, {recursive: true, force: true}); + if (context) + await context.close(); + if (userDataDir) + fs.rmSync(userDataDir, {recursive: true, force: true}); + await stopFixtureServer(fixtureProcess); } } diff --git a/qa/firefox-baseline-rdp.py b/qa/firefox-baseline-rdp.py index 6cf493e..4de16c1 100644 --- a/qa/firefox-baseline-rdp.py +++ b/qa/firefox-baseline-rdp.py @@ -144,6 +144,8 @@ def main(): "status": "passed", "details": seed_result, }) + if not ("fixture_js_host=baseline-host" in seed_result and "fixture_js_domain=baseline-domain" in seed_result): + raise RuntimeError(f"Firefox fixture cookies were not seeded: {seed_result}") client.navigate(frame["actor"], manager_url) @@ -156,11 +158,18 @@ def main(): cookies: [...document.querySelectorAll('#cookie-list li')].map(li => li.textContent.trim()) })""", ) + manager_state_data = json.loads(manager_state) results.append({ "check": "manager-domain-list", "status": "passed", - "details": json.loads(manager_state), + "details": manager_state_data, }) + if not ( + manager_state_data["title"] == "Cookie Quick Manager" + and any("lvh.me" in domain for domain in manager_state_data["domains"]) + and manager_state_data["cookies"] + ): + raise RuntimeError(f"Firefox manager did not render fixture cookies: {manager_state_data}") grouped_domains = client.evaluate( frame["consoleActor"], @@ -245,28 +254,45 @@ def main(): cookieList: [...document.querySelectorAll('#cookie-list li')].map(li => li.textContent.trim()) })""", ) + host_cookie_state_data = json.loads(host_cookie_state) results.append({ "check": "select-host-cookie", "status": "passed", - "details": json.loads(host_cookie_state), + "details": host_cookie_state_data, }) + if not ( + host_cookie_state_data["selectedDomain"] == "lvh.me" + and host_cookie_state_data["selectedName"] == "fixture_js_host" + and any("fixture_js_host" in cookie for cookie in host_cookie_state_data["cookieList"]) + ): + raise RuntimeError(f"Firefox host cookie selection failed: {host_cookie_state_data}") - normalized_protect_state = client.evaluate( + protect_icon_class = client.evaluate( frame["consoleActor"], - """(() => { - const button = document.querySelector('#protect_button'); - const icon = document.querySelector('#protect_button span'); - if (icon.className.includes('unlock')) { - button.click(); - } - return JSON.stringify({protectIconClass: icon.className}); - })()""", + "document.querySelector('#protect_button span').className", ) + if "glyphicon-unlock" in protect_icon_class: + client.evaluate(frame["consoleActor"], "document.querySelector('#protect_button').click(); true") + deadline = time.time() + 5 + while time.time() < deadline: + normalized_protect_state = client.evaluate( + frame["consoleActor"], + "JSON.stringify({protectIconClass: document.querySelector('#protect_button span').className})", + ) + normalized_protect_state_data = json.loads(normalized_protect_state) + if "glyphicon-lock" in normalized_protect_state_data["protectIconClass"]: + break + time.sleep(0.1) + else: + raise RuntimeError("Firefox protection did not activate before timeout") results.append({ "check": "protect-state-normalized", "status": "passed", - "details": json.loads(normalized_protect_state), + "details": normalized_protect_state_data, }) + protect_icon_class = normalized_protect_state_data["protectIconClass"] + if "glyphicon-lock" not in protect_icon_class or "glyphicon-unlock" in protect_icon_class: + raise RuntimeError(f"Firefox protection did not activate: {normalized_protect_state_data}") client.navigate(frame["actor"], FIXTURE_URL) _, frame = get_selected_target(client) @@ -286,6 +312,11 @@ def main(): "afterDeleteWait": after_delete, }, }) + if not ("fixture_js_host=" in before_delete and "fixture_js_host=" in after_delete): + raise RuntimeError( + "Firefox protected cookie did not survive page deletion: " + f"before={before_delete!r}, after={after_delete!r}" + ) print(json.dumps({ "ok": True, diff --git a/qa/fixture-server.mjs b/qa/fixture-server.mjs index 017ae9f..a8504f4 100644 --- a/qa/fixture-server.mjs +++ b/qa/fixture-server.mjs @@ -283,13 +283,15 @@ if (!fs.existsSync(CERT_PATH) || !fs.existsSync(KEY_PATH)) { process.exit(1); } -http.createServer(requestHandler(false)).listen(HTTP_PORT, '0.0.0.0', () => { +const LISTEN_HOST = process.env.CQM_FIXTURE_HOST || '127.0.0.1'; + +http.createServer(requestHandler(false)).listen(HTTP_PORT, LISTEN_HOST, () => { console.log(`HTTP fixture server listening on http://lvh.me:${HTTP_PORT}/`); }); https.createServer({ cert: fs.readFileSync(CERT_PATH), key: fs.readFileSync(KEY_PATH), -}, requestHandler(true)).listen(HTTPS_PORT, '0.0.0.0', () => { +}, requestHandler(true)).listen(HTTPS_PORT, LISTEN_HOST, () => { console.log(`HTTPS fixture server listening on https://lvh.me:${HTTPS_PORT}/`); }); diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..fc4db59 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,35 @@ +import {cp, mkdir, readdir, rm} from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(scriptDirectory, '..'); +const sourceDirectory = path.join(repositoryRoot, 'src'); +const target = process.argv[2] || 'chromium'; +const outputArgument = process.argv[3] || (target === 'firefox' ? 'build-firefox' : 'build'); +const outputDirectory = path.resolve(repositoryRoot, outputArgument); + +if (!['chromium', 'firefox'].includes(target)) + throw new Error(`Unknown build target: ${target}`); + +await rm(outputDirectory, {recursive: true, force: true}); +await mkdir(outputDirectory, {recursive: true}); + +for (const entry of await readdir(sourceDirectory, {withFileTypes: true})) { + if (entry.name === 'manifest.firefox.json') + continue; + if (target === 'firefox' && entry.name === 'manifest.json') + continue; + await cp( + path.join(sourceDirectory, entry.name), + path.join(outputDirectory, entry.name), + {recursive: entry.isDirectory()}, + ); +} + +if (target === 'firefox') + await cp(path.join(sourceDirectory, 'manifest.firefox.json'), path.join(outputDirectory, 'manifest.json')); +await cp(path.join(repositoryRoot, 'LICENSE'), path.join(outputDirectory, 'LICENSE')); + +console.log(`Built ${target} extension at ${path.relative(repositoryRoot, outputDirectory)}/`); diff --git a/src/_locales/de/messages.json b/src/_locales/de/messages.json index 236209f..f1459e2 100644 --- a/src/_locales/de/messages.json +++ b/src/_locales/de/messages.json @@ -380,6 +380,16 @@ } }, + "cookieRestoredExpiredSkipped": { + "message": "$number$ abgelaufene Cookie(s) übersprungen.", + "description": "Importergebnis für Cookies, deren Ablaufdatum bereits überschritten ist.", + "placeholders": { + "number": { + "content": "$1" + } + } + }, + "cookieRestoredError": { "message": "Fehler, kein Cookie wiederhergestellt.
$json_error$", "description": "Alert when a file is imported with an error from the json parser.", @@ -439,7 +449,7 @@ }, "oPrevent_protected_cookies_deletion": { - "message": " Verhindern, dass Websites geschützte Cookies löschen, auch wenn diese abgelaufen sind", + "message": " Verhindern, dass Websites geschützte Cookies ausdrücklich löschen", "description": "Settings page: Checkbox" }, @@ -613,6 +623,31 @@ "popupOptions": { "message": "Optionen", "description": "Popup menu: go to options page" + }, + + "contextMenu_domain_protect_session": { + "message": "Sitzungscookies schützen", + "description": "Option for the contextual menu that opens on a domain." + }, + + "contextMenu_domain_unprotect_session": { + "message": "Schutz für Sitzungscookies aufheben", + "description": "Option for the contextual menu that opens on a domain." + }, + + "menu_protect_session": { + "message": "Sitzungscookies schützen", + "description": "Dropdown menu option." + }, + + "menu_unprotect_session": { + "message": "Schutz für Sitzungscookies aufheben", + "description": "Dropdown menu option." + }, + + "tooltip_cSameSite": { + "message": "Teilweiser Schutz vor Cross-Site Request Forgery (CSRF) und Cross-Site Script Inclusion (XSSI).
Im strikten Modus wird das Cookie nur bei Anfragen gesendet, die von der Website stammen, von der das Cookie gesetzt wurde.", + "description": "Select menu to modify the sameSite cookie flag." } } diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index c1a84d3..9bc57d1 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -406,6 +406,16 @@ } }, + "cookieRestoredExpiredSkipped": { + "message": "$number$ expired cookie(s) skipped.", + "description": "Import result for structurally valid cookies whose expiry is already in the past.", + "placeholders": { + "number": { + "content": "$1" + } + } + }, + "cookieRestoredError": { "message": "Error, no cookie restored.
$json_error$", "description": "Alert when a file is imported with an error from the json parser.", @@ -465,7 +475,7 @@ }, "oPrevent_protected_cookies_deletion": { - "message": " Prevent sites from clearing protected cookies, even if they are expired", + "message": " Prevent sites from explicitly clearing protected cookies", "description": "Settings page: Checkbox" }, @@ -641,4 +651,4 @@ "description": "Popup menu: go to options page" } -} \ No newline at end of file +} diff --git a/src/_locales/fr/messages.json b/src/_locales/fr/messages.json index 943ed1a..7aafa2d 100644 --- a/src/_locales/fr/messages.json +++ b/src/_locales/fr/messages.json @@ -405,6 +405,16 @@ } }, + "cookieRestoredExpiredSkipped": { + "message": "$number$ cookie(s) expiré(s) ignoré(s).", + "description": "Résultat d'importation pour les cookies dont la date d'expiration est déjà passée.", + "placeholders": { + "number": { + "content": "$1" + } + } + }, + "cookieRestoredError": { "message": "Erreur, aucun cookie n'a été restauré.
$json_error$", "description": "Alert when a file is imported with an error from the json parser.", @@ -464,7 +474,7 @@ }, "oPrevent_protected_cookies_deletion": { - "message": " Empêcher les sites de supprimer des cookies protégés, même s'ils sont expirés", + "message": " Empêcher les sites de supprimer explicitement les cookies protégés", "description": "Settings page: Checkbox" }, @@ -640,4 +650,4 @@ "description": "Popup menu: go to options page" } -} \ No newline at end of file +} diff --git a/src/api.js b/src/api.js index 829bbe1..28a1606 100644 --- a/src/api.js +++ b/src/api.js @@ -27,6 +27,10 @@ if ( self.vAPI === undefined ) { } var vAPI = self.vAPI; +var core = self.CQMCore; + +if (!core) + throw new Error('CQMCore must be loaded before api.js'); vAPI.onError = function(error) { // Function called when a save/remove function has failed by throwing an exception. @@ -41,20 +45,6 @@ vAPI.onSet = function(result) { } } -vAPI.getHostUrl = function(cookie) { - // If the modified cookie has the flag isSecure, the host protocol must be https:// in order to - // modify or delete it. - var host_protocol = (cookie.secure) ? 'https://' : 'http://'; - return host_protocol + cookie.domain + cookie.path; -} - -vAPI.getHostUrl_from_UI = function() { - // If the modified cookie has the flag isSecure, the host protocol must be https:// in order to - // modify or delete it. - var host_protocol = ($('#issecure').is(':checked')) ? 'https://' : 'http://'; - return host_protocol + $('#domain').val() + $('#path').val(); -} - vAPI.parse_search_query = function(search_query) { /* Parse search queries like: * 'domain1.com domain2.com :value:"value1" :name:"name1" :name:"name2" :value:"value2"' @@ -66,52 +56,11 @@ vAPI.parse_search_query = function(search_query) { * PS: If multiple domains are present, for the moment we keep only the first one */ - function extract_terms(patterns) { - // Return a list of clean terms entered by the user: ["name1",] or ["value1",] - // Argument patterns is a list of matches: [":name:\"name1\"",] or [":value:\"value1\"",] - if (!patterns) - return []; - let re = /"(.*)"/; - let terms = []; - for (let pattern of patterns) - terms.push(pattern.match(re)[1]); - // Remove empty strings from the list of terms - return terms.filter(n => n); - } - - function get_domains(name_patterns, value_patterns) { - // Remove patterns from the query and return remaining domains - if (name_patterns) - for (let pattern of name_patterns) - search_query = search_query.replace(pattern, ''); - - if (value_patterns) - for (let pattern of value_patterns) - search_query = search_query.replace(pattern, ''); - - // Remove empty strings from the residual query - let domains = search_query.split(' ').filter(n => n); - return (!domains.length) ? [''] : domains; - } - - let name_patterns = search_query.match(/:name:"([^"]|\\")*"/g); - //console.log("parse_search_query: name_patterns:", name_patterns); - let names = extract_terms(name_patterns); - //console.log("parse_search_query: names:", names); - - let value_patterns = search_query.match(/:value:"([^"]|\\")*"/g); - //console.log("parse_search_query: value_patterns:", value_patterns); - let values = extract_terms(value_patterns); - //console.log("parse_search_query: values:", values); - - // Remove patterns from the query and get simple domains - let domains = get_domains(name_patterns, value_patterns); - //console.log("parse_search_query: domain: ", domains[0]); - - // Keep only the first domain for now - vAPI.query_domain = domains[0]; - vAPI.query_names = names; - vAPI.query_values = values; + const query = core.parseSearchQuery(search_query); + vAPI.query_domain = query.domain; + vAPI.query_names = query.names; + vAPI.query_values = query.values; + return query; } vAPI.filter_cookies = function(promise) { @@ -130,49 +79,15 @@ vAPI.filter_cookies = function(promise) { * Ex: ("name1" OR "name2") AND ("value1", "value2") */ - // No filter => return the list of cookies unchanged - if (!vAPI.query_names.length && !vAPI.query_values.length) - return promise; - - return new Promise((resolve, reject) => { - promise.then((cookies) => { - - //console.log("filter_cookies: cookie to filter", cookies.length); - - let filtered_cookies = []; - let name_found = false; - let value_found = false; - for (let cookie of cookies) { - - for (let name of vAPI.query_names) - if (cookie.name.indexOf(name) !== -1) - // name is found => keep the cookie - name_found = true; - - for (let value of vAPI.query_values) - if (cookie.value.indexOf(value) !== -1) - // value is found => keep the cookie - value_found = true; - - if ((value_found && name_found) || ( // value and name found in the same cookie - (value_found && !vAPI.query_names.length) || // value found with no queried name - (name_found && !vAPI.query_values.length) // name found with no queried value - ) - ) { - //console.log("filter_cookies: kept:", cookie.domain, cookie.name, cookie.value); - filtered_cookies.push(cookie); - } - - name_found = false; - value_found = false; - } - resolve(filtered_cookies); - }) - .catch(err => console.error(err)); - }); + return Promise.resolve(promise).then((cookies) => core.filterCookies(cookies, { + domain: '', + hostname: vAPI.query_hostname || '', + names: vAPI.query_names || [], + values: vAPI.query_values || [], + })); } -vAPI.get_all_cookies = function(storeIds) { +vAPI.get_all_cookies = async function(storeIds) { // Return a Promise with all cookies in all stores // Handle multiple stores: // - by default ALL previously queried stores are used, @@ -180,84 +95,81 @@ vAPI.get_all_cookies = function(storeIds) { // - otherwise uses storeIds argument. // Used by export.js on #clipboard_domain_export click event - return new Promise((resolve, reject) => { - if ((storeIds === undefined) || (storeIds[0] === 'all')) - storeIds = vAPI.storeIds; - - let promises = []; - for (let storeId of storeIds) { - let details = {storeId: storeId}; - if (vAPI.supportsFirstPartyIsolation) - details.firstPartyDomain = null; - - promises.push(browser.cookies.getAll(details)); - } - // Merge all promises - Promise.all(promises) - .then((cookies_array) => { - // Merge all results of promises - let cookies = Array.prototype.concat(...cookies_array); - - if (cookies.length > 0) { - - // Filtering cookies - // Filtering on domains - // PS: this step is made before the filtering of names and values - // because it is less complex and removes much more items - let filtered_cookies = []; - if (vAPI.query_domain == "") { - // vAPI.query_domain is empty: - // - get_all_cookies() is called from background script - // in case of deletion on boot - // - there is no searched domain - filtered_cookies = cookies; - } else { - for (let cookie of cookies) { - // Do not display domains different than the searched one - if (cookie.domain.indexOf(vAPI.query_domain) === -1) - continue; - - filtered_cookies.push(cookie); - } - } - //console.log("get_all_cookies: nb:", filtered_cookies.length); - resolve(filtered_cookies); - } else - reject("all_cookies-NoCookies"); - }) - .catch(err => console.error(err)); + if (!Array.isArray(storeIds) || storeIds[0] === 'all') + storeIds = vAPI.storeIds; + + if (!Array.isArray(storeIds) || !storeIds.length) + return []; + + const cookieArrays = await Promise.all(storeIds.map((storeId) => { + const details = {storeId}; + if (vAPI.supportsFirstPartyIsolation) + details.firstPartyDomain = null; + return vAPI.get_cookies(details); + })); + const cookies = cookieArrays.flat(); + return core.filterCookies(cookies, { + domain: vAPI.query_domain || '', + hostname: vAPI.query_hostname || '', + names: [], + values: [], }); } -vAPI.get_stores = function() { - // Set stores & vAPI.storeIds - // Return a promise with stores - // TODO make a function to acess to vAPI.storeIds as private attribute +vAPI.set_cookie = async function(details) { + const effectiveDetails = {...details}; + if (!effectiveDetails.storeId) + effectiveDetails.storeId = vAPI.currentContextStoreId(); + const setResult = await browser.cookies.set(effectiveDetails); + + // Chromium can return an applicable parent-domain sibling instead of the + // exact host-only cookie just written. Resolve the requested scope before + // callers protect or otherwise act on the returned identity. + const canonicalDetails = {...effectiveDetails}; + if (setResult?.storeId) + canonicalDetails.storeId = setResult.storeId; + if (setResult?.partitionKey) + canonicalDetails.partitionKey = setResult.partitionKey; + if (typeof setResult?.firstPartyDomain === 'string') + canonicalDetails.firstPartyDomain = setResult.firstPartyDomain; + const expected = core.cookieScopeFromSetDetails(canonicalDetails); + const candidates = await vAPI.get_cookies(core.buildCookieQueryDetails(expected)); + const identity = core.cookieIdentity(expected); + return candidates.find((candidate) => core.cookieIdentity(candidate) === identity) || null; +} - return new Promise((resolve, reject) => { - let allowed_incognito_access = false; +vAPI.remove_cookie = async function(cookie) { + const identity = core.cookieIdentity(cookie); + // cookies.remove() cannot express hostOnly/domain identity and may remove + // multiple applicable siblings. An expired set operation carries the full + // cookie key, so it removes only the selected scope. + await browser.cookies.set(core.buildCookieDeletionDetails(cookie)); + const candidates = await vAPI.get_cookies(core.buildCookieQueryDetails(cookie)); + return candidates.some((candidate) => core.cookieIdentity(candidate) === identity) ? null : cookie; +} - browser.extension.isAllowedIncognitoAccess().then((allowed) => { - allowed_incognito_access = allowed; - //console.log("get_stores:: allowed incognito access?", allowed_incognito_access); - //console.log({default_stores: vAPI.default_stores}); +vAPI.get_cookies = async function(details = {}) { + const queries = [browser.cookies.getAll(details)]; + if (vAPI.supportsPartitionedCookies && details.partitionKey === undefined) + queries.push(browser.cookies.getAll({...details, partitionKey: {}})); - if (!allowed_incognito_access) { - // The extension is not allowed to access private windows - // Keep only default context - vAPI.storesAllowed = [vAPI.default_stores[0]]; - } else { - // Keep all default contexts - vAPI.storesAllowed = vAPI.default_stores; - } + const cookies = (await Promise.all(queries)).flat(); + const uniqueCookies = new Map(); + for (const cookie of cookies) + uniqueCookies.set(core.cookieIdentity(cookie), cookie); + return [...uniqueCookies.values()]; +} - if (!vAPI.supportsContextualIdentities) - return browser.cookies.getAllCookieStores(); +vAPI.get_stores = async function() { + // Set stores & vAPI.storeIds + // Return a promise with stores + // TODO make a function to acess to vAPI.storeIds as private attribute - // Query other contexts - return browser.contextualIdentities.query({}); - }) - .then((contexts_or_cookie_stores) => { + const allowed_incognito_access = await browser.extension.isAllowedIncognitoAccess(); + vAPI.storesAllowed = allowed_incognito_access ? vAPI.default_stores : [vAPI.default_stores[0]]; + const contexts_or_cookie_stores = vAPI.supportsContextualIdentities ? + await browser.contextualIdentities.query({}) : + await browser.cookies.getAllCookieStores(); // contexts === false on Firefox < 57 // on FF57- contexts doesn't contain default stores: firefox-private or firefox-default //console.log({CONTEXTS: contexts}); @@ -313,15 +225,10 @@ vAPI.get_stores = function() { }); //console.log({Stores: stores}); - resolve(stores); - - }, (error) => { - console.error(error); - }); - }); + return stores; } -vAPI.FPI_detection = function(promise) { +vAPI.FPI_detection = async function(promise) { // Set the attribute vAPI.FPI with the status of First Party Isolation // This promise is made to be chained before all promises that call // browser.cookies.* on browser that can support or not this new API. @@ -330,34 +237,19 @@ vAPI.FPI_detection = function(promise) { if (!vAPI.supportsFirstPartyIsolation) { vAPI.FPI = undefined; - return Promise.resolve(promise); + return promise; } - return new Promise((resolves, rejects) => { - // This promise will crash on FF 59- - // The error is captured by the error callback. - //console.log('Test availability of firstPartyIsolate API'); - resolves(browser.privacy.websites.firstPartyIsolate.get({})); - - }) - .then((got) => { - // First Party Isolation is supported (FF 58+=) - //console.log('firstPartyIsolate API IS available'); - // set FPI status to true or false + try { + const got = await browser.privacy.websites.firstPartyIsolate.get({}); vAPI.FPI = got.value; - //console.log({FPI_status: vAPI.FPI}); - return promise; - - }, (error) => { - //console.log('firstPartyIsolate API is NOT available'); - // set FPI status + } catch (error) { vAPI.FPI = undefined; - //console.log({FPI_status: vAPI.FPI}); - return promise; - }); + } + return promise; } -vAPI.delete_cookies = function(promise) { +vAPI.delete_cookies = async function(promise) { // Delete all cookies in the promise // Return a promise // PS: there is no verification of the support of FPI here @@ -368,165 +260,45 @@ vAPI.delete_cookies = function(promise) { // they are protected against deletion) // NOTE: This function does not try to delete protected cookie - return new Promise((resolve, reject) => { - - // DO NOT delete protected cookies - var protected_cookies; - var number_of_given_cookies; - - browser.storage.local.get({ - protected_cookies: {}, - }) - .then((items) => { - - protected_cookies = items.protected_cookies; - return promise; - }) - .then((cookies) => { - let promises = []; - number_of_given_cookies = cookies.length; - - for (let cookie of cookies) { - // DO NOT delete protected cookies - if (cookie.domain in protected_cookies - && protected_cookies[cookie.domain].indexOf(cookie.name) !== -1) - continue; - - // Remove current cookie - let params = { - url: vAPI.getHostUrl(cookie), - name: cookie.name, - storeId: cookie.storeId, - }; - - // Handle FPI property - if (cookie.firstPartyDomain !== undefined) - params.firstPartyDomain = cookie.firstPartyDomain; - - //console.log({value: cookie.value, firstPartyDomain: cookie.firstPartyDomain}); - promises.push(browser.cookies.remove(params)); - } - // Merge all promises - return Promise.all(promises); - }) - .then((cookies_array) => { - // Iter on all results of promises - for (let deleted_cookie of cookies_array) { - - // If null: no error but no suppression - // => display button content in red - if (deleted_cookie === null) { - console.log({"Not removed": deleted_cookie}); - // => display button content in red - reject("No error but not removed"); - } - // console.log({"Removed": deleted_cookie}); - } - // Ok => all cookies are deleted properly - // Reactivate the interface - // Return the number of remaining cookies - resolve(number_of_given_cookies - cookies_array.length); - }, vAPI.onError); - }); + const [items, cookies] = await Promise.all([ + browser.storage.local.get(null), + Promise.resolve(promise), + ]); + const protectedCookies = core.protectedCookiesFromStorage(items); + const deletableCookies = cookies.filter((cookie) => !core.isCookieProtected(cookie, protectedCookies)); + const results = await Promise.allSettled(deletableCookies.map((cookie) => vAPI.remove_cookie(cookie))); + const failures = results.filter((result) => result.status === 'rejected' || result.value === null); + if (failures.length) + throw new Error(`${failures.length} cookie(s) could not be removed.`); + return cookies.length - deletableCookies.length; } -vAPI.copy_cookies_to_store = function(promise, store_id) { +vAPI.copy_cookies_to_store = async function(promise, store_id) { // Copy a set of cookies to the store with the given store_id // Return a promise - return new Promise((resolve, reject) => { - promise.then((cookies) => { - - let promises = []; - for (let cookie of cookies) { - // Build cookie - let params = { - url: vAPI.getHostUrl(cookie), - name: cookie.name, - value: cookie.value, - path: cookie.path, - httpOnly: cookie.httpOnly, - secure: cookie.secure, - storeId: store_id, - }; - - // Handle optional sameSite flag if supported - if (cookie.sameSite != null) - params['sameSite'] = cookie.sameSite; - - // Session cookie has no expiration date - if (!cookie.session) { - // Refuse expired cookies - if (cookie.expirationDate <= ((Date.now() / 1000|0) + 1)) - continue; - params['expirationDate'] = cookie.expirationDate; - } - - // Handle FPI property - if (cookie.firstPartyDomain !== undefined) - params.firstPartyDomain = cookie.firstPartyDomain; - - promises.push(browser.cookies.set(params)); - } - // Merge all promises - return vAPI.add_cookies(Promise.all(promises)); - }) - .then((ret) => { - // PS: when add_cookies raises an error, it handles itself this error, - // so we can end up here right after. - console.log("copy_cookies_to_store has ended"); - resolve(); - }, vAPI.onError); - }); + const cookies = await Promise.resolve(promise); + const setPromises = cookies + .filter((cookie) => cookie.session || cookie.expirationDate > ((Date.now() / 1000 | 0) + 1)) + .map((cookie) => vAPI.set_cookie(core.buildCookieSetDetails({...cookie, storeId: store_id}))); + return vAPI.add_cookies(Promise.all(setPromises)); } -vAPI.add_cookies = function(new_cookies_promises, protection_status) { +vAPI.add_cookies = async function(new_cookies_promises, protection_status) { // Add given cookies to the cookie store // Used in export.js and api.js // Take a promise on new_cookies_promises // Return a promise - if (protection_status === undefined) - protection_status = false; - - return new Promise((resolve, reject) => { - - new_cookies_promises.then((cookies_array) => { - // Iter on all results of promises - for (let added_cookie of cookies_array) { - - // If null: no error but no save - if (added_cookie === null) { - console.log({"Not added": added_cookie}); - reject("Cookie " + JSON.stringify(added_cookie) + " can't be saved"); - } - //console.log({"Added": added_cookie}); - } - - // Protect all cookies if asked in global settings - if (protection_status) - return vAPI.set_cookie_protection(cookies_array, true); - - }, (error) => { - // Handle errors from browser.cookies.set promises - // Ex: errors due to access to the private cookies storeId - vAPI.onError(error); - reject(JSON.stringify(error.message)); - - }).then(() => { - // Ok => all cookies are added/protected properly - // Reactivate the interface - resolve(); - }, (error) => { - // Errors while adding the cookies, - // or while the protection of cookies. - // TODO: make a proper message - reject(JSON.stringify(error)); - }); - }); + const cookies = await Promise.resolve(new_cookies_promises); + if (cookies.some((cookie) => cookie === null)) + throw new Error('At least one cookie could not be saved.'); + if (protection_status) + await vAPI.set_cookie_protection(cookies, true); + return cookies; } -vAPI.getCookiesFromSelectedDomain = function() { +vAPI.getCookiesFromSelectedDomain = async function() { // Return a Promise with cookies that belong to the selected domain; // Return also cookies for subdomains if the subdomain checkbox is checked. // https://developer.mozilla.org/fr/docs/Web/JavaScript/Reference/Objets_globaux/Promise @@ -535,132 +307,115 @@ vAPI.getCookiesFromSelectedDomain = function() { // => la fonction doit prendre directement la liste des domaines, les stores, l'état de query-subdomains // Used by export.js on #clipboard_domain_export click event - return new Promise((resolve, reject) => { + const domainObject = document.querySelector('#domain-list li.active'); + if (!domainObject) + throw new Error('SelectedDomain-NoDomain'); + const domainQuery = $(domainObject).data('domainQuery'); + if (!domainQuery) + throw new Error('SelectedDomain-NoQuery'); + var domain = domainQuery.id; + var storeIds = domainQuery.storeIds; + // TODO: simulate multiple domains + var domains = [domain, ]; + let promises = []; + for (let domain of domains) { + for (let storeId of storeIds) { + let details = {domain: domain, storeId: storeId}; + if (vAPI.supportsFirstPartyIsolation) + details.firstPartyDomain = null; - // Workaround to get click event data of the selected domain - // Get pure HTML document (not a JQuery one) - var domain_obj = document.querySelector('#domain-list li.active'); - if (!domain_obj) { - reject("SelectedDomain-NoDomain"); - return; + promises.push(vAPI.get_cookies(details)); } - //console.log($._data(domain, "events" )); - // Get data of the first click event registered - var click_event_data = $._data(domain_obj, "events" ).click[0].data - var domain = click_event_data.id; - var storeIds = click_event_data.storeIds; - // TODO: simulate multiple domains - var domains = [domain, ]; - let promises = []; - for (let domain of domains) { - for (let storeId of storeIds) { - let details = {domain: domain, storeId: storeId}; - if (vAPI.supportsFirstPartyIsolation) - details.firstPartyDomain = null; - - promises.push(browser.cookies.getAll(details)); + } + const cookies_array = await Promise.all(promises); + // Merge all results of promises + let cookies = Array.prototype.concat(...cookies_array); + + if (cookies.length > 0) { + let filtered_cookies = []; + let query_subdomains = $('#query-subdomains').is(':checked'); + if (query_subdomains) { + filtered_cookies = cookies; + } else { + // Sub domains are not wanted here + for (let cookie of cookies) { + if (domains.indexOf(cookie.domain) !== -1) + filtered_cookies.push(cookie); } } - Promise.all(promises) - .then((cookies_array) => { - // Merge all results of promises - let cookies = Array.prototype.concat(...cookies_array); - - if (cookies.length > 0) { - - // Filtering cookies - // Filtering on domains - // PS: this step is made before the filtering of names and values - // because it is less complex and removes much more items - let filtered_cookies = []; - let query_subdomains = $('#query-subdomains').is(':checked'); - if (query_subdomains) { - filtered_cookies = cookies; - } else { - // Sub domains are not wanted here - for (let cookie of cookies) { - // Filter on exact domain (remove sub domains from the list) - // If current domain is not found in domains => go to next cookie - if (domains.indexOf(cookie.domain) !== -1) - filtered_cookies.push(cookie); - } - } - //console.log("getCookiesFromSelectedDomain: nb", filtered_cookies.length); - resolve(filtered_cookies); - } else { - reject("SelectedDomain-NoCookies"); - } - }); - }); + return filtered_cookies; + } else { + throw new Error('SelectedDomain-NoCookies'); + } } -vAPI.set_cookie_protection = function(cookies, protect_flag) { +vAPI.commit_cookie_protection = function(cookies, protect_flag) { // Iterate on all new cookies and add their domains and names to the // array of protected_cookies in local storage. // protect_flag: false: unprotect the cookies; true: protect the cookies // TODO: make a global promise shared with cookies.js (#protect_button.click) to check // the presence of a domain in protected_cookies - return new Promise((resolve, reject) => { - - browser.storage.local.get({ - protected_cookies: {}, - }) - .then((items) => { - for (let cookie of cookies) { - //console.log(cookie); - - // Check domain - let domain = cookie.domain; - if (!(domain in items.protected_cookies)) { - if (protect_flag) - // Absent: we want to protect it: init domain - items.protected_cookies[domain] = []; - else - // Absent we want to unprotect: do nothing - continue; - } - - // Check name - let name = cookie.name; - if (protect_flag && items.protected_cookies[domain].indexOf(name) === -1) { - // This cookie will be protected - console.log({'protect: add': name}); - items.protected_cookies[domain].push(name); - continue; - } - - if ((!protect_flag) && (items.protected_cookies[domain].indexOf(name) !== -1)) { - // This cookie will not be protected anymore - console.log({'protect: rm': name}); - // Remove the current cookie name from list if it is already present - items.protected_cookies[domain] = items.protected_cookies[domain].filter(present_name => { - // To delete the cookie name, we have to return false if name == present_name - // So, return true if name != present_name - return name != present_name; - }); - } + const operation = vAPI.protectionUpdateQueue.then(async () => { + const items = await browser.storage.local.get(null); + if (protect_flag) { + const additions = {}; + for (const cookie of cookies) { + const record = core.makeProtectionRecord(cookie); + additions[core.protectionStorageKey(cookie)] = {domain: cookie.domain, record}; } - - // Clean empty domains => better privacy - let cleaned_protected_cookies = {}; - for (let i in items.protected_cookies) { - if (items.protected_cookies[i].length != 0) { - cleaned_protected_cookies[i] = items.protected_cookies[i]; + if (Object.keys(additions).length) + await browser.storage.local.set(additions); + } else { + const keys = new Set(cookies.map((cookie) => core.protectionStorageKey(cookie))); + for (const [key, value] of Object.entries(items)) { + if (!core.isProtectionStorageKey(key) || !value || typeof value !== 'object') + continue; + for (const cookie of cookies) { + if (value.domain === cookie.domain && + core.protectionRecordMatches(value.record, cookie)) + keys.add(key); } } + if (keys.size) + await browser.storage.local.remove([...keys]); + + // Legacy domain -> [name] rules are retained for compatibility and + // removed only when the user explicitly unprotects a matching cookie. + const legacy = core.normalizeProtectedCookies(items.protected_cookies); + const updatedLegacy = core.updateProtectionMap(legacy, cookies, false); + if (JSON.stringify(updatedLegacy) !== JSON.stringify(legacy)) + await browser.storage.local.set({protected_cookies: updatedLegacy}); + } + return vAPI.get_protected_cookies(); + }); + vAPI.protectionUpdateQueue = operation.catch(() => {}); + return operation; +} - // Set new protected_cookies on storage area - return browser.storage.local.set({"protected_cookies": cleaned_protected_cookies}); - }) - .then(() => { - resolve(); - - }, (error) => { - console.log({"Error during protection:": error, "Protection flag:": protect_flag}); - reject({"Error during protection:": error, "Protection flag:": protect_flag}); - }); +vAPI.set_cookie_protection = async function(cookies, protect_flag) { + const safeCookies = cookies.map((cookie) => ({ + domain: String(cookie.domain ?? ''), + name: String(cookie.name ?? ''), + path: core.normalizePath(cookie.path), + storeId: String(cookie.storeId ?? ''), + hostOnly: core.parseBoolean(cookie.hostOnly, !String(cookie.domain ?? '').startsWith('.')), + ...(typeof cookie.firstPartyDomain === 'string' ? {firstPartyDomain: cookie.firstPartyDomain} : {}), + ...(core.clonePartitionKey(cookie.partitionKey) ? {partitionKey: core.clonePartitionKey(cookie.partitionKey)} : {}), + })); + const response = await browser.runtime.sendMessage({ + type: 'cqm:update-protection', + cookies: safeCookies, + protect: protect_flag === true, }); + if (!response?.ok) + throw new Error(response?.error || 'Protection update failed.'); + return core.normalizeProtectedCookies(response.protectedCookies); +} + +vAPI.get_protected_cookies = async function(storage_items) { + const items = storage_items || await browser.storage.local.get(null); + return core.protectedCookiesFromStorage(items); } vAPI.setFirstPartyIsolateStatus = function(status) { @@ -695,16 +450,14 @@ vAPI.get_and_patch_protected_cookies = function(storage_items) { // Return the associative array of protected_cookies. // Return an empty associative array if something happened - // The array check is a workaround to fix previous bug e4e735f (an array instead of an object) - if (!Array.isArray(storage_items.protected_cookies)) - return storage_items.protected_cookies; - else { - // protected_cookies is an Array - // Init data structure - let set_settings = browser.storage.local.set({"protected_cookies": {}}); - set_settings.then(null, onError); - return {}; - } + const protectedCookies = core.normalizeProtectedCookies(storage_items.protected_cookies); + if (JSON.stringify(protectedCookies) !== JSON.stringify(storage_items.protected_cookies)) + browser.storage.local.set({protected_cookies: protectedCookies}).catch(vAPI.onError); + return protectedCookies; +} + +vAPI.is_cookie_protected = function(cookie, protected_cookies) { + return core.isCookieProtected(cookie, protected_cookies); } vAPI.get_session_cookies = function(cookies) { @@ -722,7 +475,7 @@ vAPI.get_session_cookies = function(cookies) { vAPI.ask_permission = function(permission_name) { // Ask the given permission to the browser // PS: Due to restrictions, this function must be called from a user input handler - browser.permissions.request({permissions: [permission_name]}) + return browser.permissions.request({permissions: [permission_name]}) .then((response) => { console.log("ask_permission:", permission_name, response); }) @@ -731,7 +484,7 @@ vAPI.ask_permission = function(permission_name) { vAPI.remove_permission = function(permission_name) { // Remove a permission - browser.permissions.remove({permissions: [permission_name]}) + return browser.permissions.remove({permissions: [permission_name]}) .catch(err => console.error(err)); } @@ -741,6 +494,7 @@ vAPI.remove_permission = function(permission_name) { // This attribute is "public" and should be used instead of vAPI.default_stores vAPI.storesAllowed = []; vAPI.storeIds = []; //Ex: ['firefox-default', 'firefox-private', ...]; +vAPI.protectionUpdateQueue = Promise.resolve(); vAPI.template_JSON = { name: 'JSON', @@ -787,7 +541,7 @@ vAPI.optimal_window_width = 1095; vAPI.optimal_window_height = 640; vAPI.query_domain = ""; -vAPI.query_names; -vAPI.query_values; +vAPI.query_names = []; +vAPI.query_values = []; -})(globalThis); \ No newline at end of file +})(globalThis); diff --git a/src/background-script.js b/src/background-script.js index a8673d4..716784f 100644 --- a/src/background-script.js +++ b/src/background-script.js @@ -18,177 +18,164 @@ * * Home: https://github.com/ysard/cookie-quick-manager */ -/*********** Update patchs ***********/ 'use strict'; -function update_listener(details) { - /* Fired when the extension is first installed, when the extension is updated - * to a new version, and when the browser is updated to a new version. - */ - //console.log({update_addon: details}); - - // Detect the current platform - let gettingInfo = browser.runtime.getPlatformInfo(); - gettingInfo.then((info) => { - // On Android, the addon must be opened in a new tab - if (info.os == 'android') - return browser.storage.local.set({open_in_new_tab: true}); - }) - .catch((error) => { - console.log(`set_option_error: ${error}`); - }); +(function(self) { +const core = self.CQMCore; +if (!core) + throw new Error('CQMCore must be loaded before background-script.js'); + +let protectedCookies = {}; +let preventProtectedCookieDeletion = true; +let deleteAllOnRestart = false; +const restoreTimers = new Map(); + +async function loadOptions() { + const items = await browser.storage.local.get(null); + protectedCookies = core.protectedCookiesFromStorage(items); + preventProtectedCookieDeletion = items.prevent_protected_cookies_deletion !== false; + deleteAllOnRestart = items.delete_all_on_restart === true; + return items; } -browser.runtime.onInstalled.addListener(update_listener); - - -/*********** Utils ***********/ +let optionsReady = loadOptions().catch((error) => { + vAPI.onError(error); + protectedCookies = {}; +}); -function onError(error) { - // Function called when a save/remove function has failed by throwing an exception. - console.log({"Error removing/saving cookie:": error}); +async function handleInstalled() { + const info = await browser.runtime.getPlatformInfo(); + if (info.os === 'android') + await browser.storage.local.set({open_in_new_tab: true}); } -function get_cookie_restore_key(cookie) { - return [ - cookie.domain, - cookie.path, - cookie.name, - cookie.storeId, - cookie.firstPartyDomain || '', - ].join('::'); -} +async function runStartupCleanup() { + await loadOptions(); + if (!deleteAllOnRestart) + return {deleted: false}; -function is_protected_cookie(cookie) { - return !(protected_cookies[cookie.domain] === undefined || - protected_cookies[cookie.domain].indexOf(cookie.name) === -1); + await vAPI.get_stores(); + const cookies = await vAPI.get_all_cookies(); + const protectedCount = await vAPI.delete_cookies(Promise.resolve(cookies)); + return {deleted: true, examined: cookies.length, protectedCount}; } -function build_cookie_restore_params(cookie) { - let params = { - url: vAPI.getHostUrl(cookie), - name: cookie.name, - value: cookie.value, - path: cookie.path, - httpOnly: cookie.httpOnly, - secure: cookie.secure, - storeId: cookie.storeId, - }; - - if (cookie.expirationDate !== undefined) - params.expirationDate = cookie.expirationDate; - - if (cookie.firstPartyDomain !== undefined) - params.firstPartyDomain = cookie.firstPartyDomain; - - return params; +function cancelScheduledRestore(cookie) { + const key = core.cookieIdentity(cookie); + const timer = restoreTimers.get(key); + if (timer !== undefined) { + clearTimeout(timer); + restoreTimers.delete(key); + } } -function schedule_cookie_restore(cookie) { - let cookie_restore_key = get_cookie_restore_key(cookie); - - if (restore_timers[cookie_restore_key] !== undefined) - clearTimeout(restore_timers[cookie_restore_key]); - - restore_timers[cookie_restore_key] = setTimeout(() => { - delete restore_timers[cookie_restore_key]; +async function restoreCookieIfStillMissing(cookie) { + // cookies.get() may return a host-only/domain sibling with the same name. + // Query candidates and compare the complete CQM identity before deciding + // that this exact protected cookie survived. + const candidates = await vAPI.get_cookies(core.buildCookieQueryDetails(cookie)); + const identity = core.cookieIdentity(cookie); + const existingCookie = candidates.find((candidate) => core.cookieIdentity(candidate) === identity); + if (existingCookie) + return existingCookie; + return vAPI.set_cookie(core.buildCookieSetDetails(cookie)); +} - let promise = browser.cookies.set(build_cookie_restore_params(cookie)); - promise.then((restored_cookie) => { - console.log({"Erasure protection: Cookie NOT deleted!:": restored_cookie}); - }, onError); +function scheduleCookieRestore(cookie) { + const key = core.cookieIdentity(cookie); + cancelScheduledRestore(cookie); + const timer = setTimeout(() => { + restoreTimers.delete(key); + restoreCookieIfStillMissing(cookie).catch(vAPI.onError); }, 150); + restoreTimers.set(key, timer); } -function init_options() { - // Get & set options from storage - // Init protected_cookies array in global context - // Delete cookies (on restart) if the user has selected the option - // This function is called on each startup and when the addon is installed to the browser +async function handleCookieChange(changeInfo) { + await optionsReady; - let get_settings = browser.storage.local.get({ - protected_cookies: {}, - delete_all_on_restart: false, - prevent_protected_cookies_deletion: true, - }); - get_settings.then((items) => { - // Load protected_cookies - protected_cookies = vAPI.get_and_patch_protected_cookies(items); - - // Load the flag to prevent protected cookies deletion by websites - prevent_protected_cookies_deletion = items.prevent_protected_cookies_deletion; - - // Program the deletion of all cookies (except for those which are protected) - // BUG ?: We must set a delay on this function. Otherwise the API returns 0 cookie... - if (items.delete_all_on_restart) - setTimeout(function() { - vAPI.get_stores().then((stores) => { - vAPI.delete_cookies(vAPI.get_all_cookies()) - .catch(vAPI.onError); - }); - }, 2000); - }); -} + // A new value for the same exact cookie wins over a pending restore. This + // prevents stale protected authentication state from replacing a rotation. + if (!changeInfo.removed) { + cancelScheduledRestore(changeInfo.cookie); + return; + } -/*********** Events ***********/ -browser.cookies.onChanged.addListener(function(changeInfo) { - /* Callback when the cookie store is updated - * PS: not called when you try to overwrite the exact same cookie. - * - * Update of an expired cookie: - * Object { removed: true, cookie: Object, cause: "expired" } - * Object { removed: false, cookie: Object, cause: "explicit" } - * - * Update of a valid cookie: - * Object { removed: true, cookie: Object, cause: "overwrite" } - * Object { removed: false, cookie: Object, cause: "explicit" } - * - * Delete event (when a past date is set or cookie.remove() is called): - * PS: It seems to be impossible to remove an expired coookie by setting a past date. - * Object {removed: true, cookie: Object, cause: "explicit" } - * - * Add event: - * Object {removed: false, cookie: Object, cause: "explicit" } - */ - - // Do not protect the cookie if website protection is not enabled - if (!prevent_protected_cookies_deletion || !changeInfo.removed) + if (changeInfo.cause === 'overwrite') { + cancelScheduledRestore(changeInfo.cookie); return; + } - // Ignore remove events emitted as part of a regular cookie update. - if (changeInfo.cause == 'overwrite') + // Natural expiry and browser eviction are lifecycle decisions, not a + // site/API deletion. Restoring them would silently make cookies immortal. + if (!['explicit', 'expired_overwrite'].includes(changeInfo.cause)) return; - if (!is_protected_cookie(changeInfo.cookie)) + if (!preventProtectedCookieDeletion || !core.isCookieProtected(changeInfo.cookie, protectedCookies)) return; + scheduleCookieRestore(changeInfo.cookie); +} - schedule_cookie_restore(changeInfo.cookie); +browser.runtime.onInstalled.addListener(() => { + handleInstalled().catch(vAPI.onError); }); -browser.storage.onChanged.addListener(function (changes, area) { - // Called when the local storage area is modified - // Here: we handle only 'protected_cookies' and 'prevent_protected_cookies_deletion' keys. - // We do that here because we have to know if a cookie must be - // protected or not from deletion when there is a deletion event. - - //console.log("Change in storage area: " + area); - //console.log(changes); - if (changes['protected_cookies'] !== undefined) - protected_cookies = changes.protected_cookies.newValue; - - if (changes['prevent_protected_cookies_deletion'] !== undefined) - prevent_protected_cookies_deletion = changes.prevent_protected_cookies_deletion.newValue; +browser.runtime.onStartup.addListener(() => { + runStartupCleanup().catch(vAPI.onError); +}); +browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.type !== 'cqm:update-protection') + return false; + if (sender.id !== browser.runtime.id) { + sendResponse({ok: false, error: 'Untrusted message sender.'}); + return false; + } + if (!Array.isArray(message.cookies) || message.cookies.length > 10000 || + typeof message.protect !== 'boolean') { + sendResponse({ok: false, error: 'Invalid protection update.'}); + return false; + } + const cookies = message.cookies.filter((cookie) => cookie && + typeof cookie.domain === 'string' && typeof cookie.name === 'string' && + typeof cookie.path === 'string' && typeof cookie.storeId === 'string'); + if (cookies.length !== message.cookies.length) { + sendResponse({ok: false, error: 'Invalid cookie identity.'}); + return false; + } + vAPI.commit_cookie_protection(cookies, message.protect).then((updatedProtection) => { + // Make the new policy visible before acknowledging the mutation so a + // caller can safely delete immediately after protecting. + protectedCookies = core.normalizeProtectedCookies(updatedProtection); + sendResponse({ok: true, protectedCookies: {...protectedCookies}}); + }, (error) => { + sendResponse({ok: false, error: error.message}); + }); + return true; }); -//browser.runtime.onStartup.addListener(init_options); +browser.cookies.onChanged.addListener((changeInfo) => { + handleCookieChange(changeInfo).catch(vAPI.onError); +}); -/*********** Global variables ***********/ -//var protected_cookies_counter = 0; -var protected_cookies; -var prevent_protected_cookies_deletion; -var restore_timers = {}; +browser.storage.onChanged.addListener((changes, area) => { + if (area !== 'local') + return; + if (changes.protected_cookies !== undefined) + optionsReady = loadOptions().catch(vAPI.onError); + if (Object.keys(changes).some((key) => core.isProtectionStorageKey(key))) + optionsReady = loadOptions().catch(vAPI.onError); + if (changes.prevent_protected_cookies_deletion !== undefined) + preventProtectedCookieDeletion = changes.prevent_protected_cookies_deletion.newValue !== false; + if (changes.delete_all_on_restart !== undefined) + deleteAllOnRestart = changes.delete_all_on_restart.newValue === true; +}); -init_options(); -// Set default color of the counter of protected cookies on the toolbar icon -//browser.browserAction.setBadgeBackgroundColor({color: 'black'}); \ No newline at end of file +// Exposed for adapter contract tests; production code uses the listeners above. +self.CQMBackground = Object.freeze({ + handleCookieChange, + loadOptions, + restoreCookieIfStillMissing, + runStartupCleanup, +}); +})(globalThis); diff --git a/src/browser-shim.js b/src/browser-shim.js new file mode 100644 index 0000000..ef98c30 --- /dev/null +++ b/src/browser-shim.js @@ -0,0 +1,5 @@ +/* Lightweight browser namespace compatibility for current Chromium builds. */ +'use strict'; + +if (typeof globalThis.browser === 'undefined' && typeof globalThis.chrome !== 'undefined') + globalThis.browser = globalThis.chrome; diff --git a/src/cookies.html b/src/cookies.html index 27e33af..ddcb588 100644 --- a/src/cookies.html +++ b/src/cookies.html @@ -3,7 +3,8 @@ - + + @@ -64,6 +65,13 @@ right: 0px; top: 0px; } + .partition-badge { + float: right; + margin-right: 4px; + max-width: 45%; + overflow: hidden; + text-overflow: ellipsis; + } .cookie-expired, .cookie-expired.active, .cookie-expired.active:hover, .button-error { color: red; } @@ -395,6 +403,12 @@

Details

+
@@ -623,4 +637,4 @@
- +
-

First-Party Isolation is available only when the Firefox privacy API exposes it.

-

The firstPartyIsolate preference makes the browser associate all data - (including cookies, HSTS data, cached images, and more) for any third party domains with - the domain in the address bar. This prevents third party trackers from using directly stored - information to identify the user across different websites, but may break websites where the - user logs in with a third party account (such as a Facebook or Google account).

-

More information at Mozilla Developer Network.

-

This control is Firefox-only and is hidden on Chromium.

+

First-Party Isolation is available only when the Firefox privacy API exposes it.

+

The firstPartyIsolate preference associates third-party data with the domain in the address bar. This limits cross-site tracking but can break third-party sign-in.

+

More information at Mozilla Developer Network.

+

This control is Firefox-only and is hidden on Chromium.

Appearance

@@ -190,7 +186,7 @@

Management of protected co
  • Source code (Under GPLv3 license)
  • -
  • Report an issue
  • +
  • Report an issue
  • Thanks (help and bug reports): Gschanuel, Killogy, Laniakea64, Lejomoz, Machou, Practik, Seanryan-seanryan, bendover22, bughit, AX-Turbo, StanGets, Sane-Max, SpaceClicker, grandemestre, iG8R.
  • Translations: Cyanotyp (de), Scheinercc (de).
  • External dependencies (GPLv3 compatible): diff --git a/src/options.js b/src/options.js index 37872bd..f29bfe1 100644 --- a/src/options.js +++ b/src/options.js @@ -170,25 +170,9 @@ function get_options() { // Load options from storage and update the interface - let get_settings = browser.storage.local.get({ - delete_all_on_restart: false, - import_protected_cookies: false, - prevent_protected_cookies_deletion: true, - skin: 'default', - open_in_new_tab: true, - display_deletion_alert: true, - template: 'JSON', - }); + let get_settings = browser.storage.local.get(core.getDefaultSettings()); return get_settings.then((items) => { - items = Object.assign({ - delete_all_on_restart: false, - import_protected_cookies: false, - prevent_protected_cookies_deletion: true, - skin: 'default', - open_in_new_tab: true, - display_deletion_alert: true, - template: 'JSON', - }, core.sanitizeSettings(items)); + items = Object.assign(core.getDefaultSettings(), core.sanitizeSettings(items)); //console.log({storage_data: items}); // Update the interface @@ -268,10 +252,12 @@ for (const [domain, records] of Object.entries(protectedCookies)) { const fieldset = document.createElement('fieldset'); const legend = document.createElement('legend'); + const domainLabel = document.createElement('label'); const selectDomain = document.createElement('input'); selectDomain.type = 'checkbox'; const domainText = document.createTextNode(` ${domain} (${records.length})`); - legend.append(selectDomain, domainText); + domainLabel.append(selectDomain, domainText); + legend.appendChild(domainLabel); fieldset.appendChild(legend); const entries = []; diff --git a/test/background.test.js b/test/background.test.js index c3d0aac..304474e 100644 --- a/test/background.test.js +++ b/test/background.test.js @@ -36,6 +36,14 @@ function createBackgroundHarness(options = {}) { }; const calls = {cleanup: 0, sets: [], errors: []}; let currentCookies = []; + let nextTimerId = 1; + const pendingTimers = new Map(); + const scheduleTimer = (callback) => { + const id = nextTimerId++; + pendingTimers.set(id, callback); + return id; + }; + const cancelTimer = (id) => pendingTimers.delete(id); const browser = { runtime: { id: 'test-extension', @@ -71,7 +79,8 @@ function createBackgroundHarness(options = {}) { commit_cookie_protection: async () => ({}), }; const context = vm.createContext({ - browser, CQMCore: core, console, globalThis: null, setTimeout, clearTimeout, vAPI, + browser, CQMCore: core, console, globalThis: null, + setTimeout: scheduleTimer, clearTimeout: cancelTimer, vAPI, }); context.globalThis = context; vm.runInContext(fs.readFileSync(path.resolve(__dirname, '../src/background-script.js'), 'utf8'), context); @@ -79,6 +88,12 @@ function createBackgroundHarness(options = {}) { background: context.CQMBackground, calls, events, + async flushRestoreTimers() { + const callbacks = [...pendingTimers.values()]; + pendingTimers.clear(); + callbacks.forEach((callback) => callback()); + await new Promise((resolve) => setImmediate(resolve)); + }, setCurrentCookie(value) { currentCookies = value ? [value] : []; }, setCurrentCookies(value) { currentCookies = value; }, targetCookie, @@ -97,7 +112,7 @@ test('worker evaluation never performs startup cleanup', async () => { test('protected cookie restore preserves exact security and domain semantics', async () => { const harness = createBackgroundHarness({protected: true}); await harness.background.handleCookieChange({removed: true, cause: 'explicit', cookie: harness.targetCookie}); - await new Promise((resolve) => setTimeout(resolve, 190)); + await harness.flushRestoreTimers(); assert.equal(harness.calls.sets.length, 1); assert.equal(harness.calls.sets[0].sameSite, 'strict'); assert.equal(harness.calls.sets[0].httpOnly, true); @@ -117,7 +132,7 @@ test('a host/domain sibling does not suppress restoration of the exact protected cause: 'explicit', cookie: harness.targetCookie, }); - await new Promise((resolve) => setTimeout(resolve, 190)); + await harness.flushRestoreTimers(); assert.equal(harness.calls.sets.length, 1); assert.equal(harness.calls.sets[0].domain, undefined); assert.equal(harness.calls.sets[0].value, 'old'); @@ -129,14 +144,14 @@ test('fresh cookie rotation cancels a pending stale restore', async () => { const freshCookie = {...harness.targetCookie, value: 'fresh'}; harness.setCurrentCookie(freshCookie); await harness.background.handleCookieChange({removed: false, cause: 'explicit', cookie: freshCookie}); - await new Promise((resolve) => setTimeout(resolve, 190)); + await harness.flushRestoreTimers(); assert.equal(harness.calls.sets.length, 0); }); test('natural cookie expiry is not restored as an immortal session cookie', async () => { const harness = createBackgroundHarness({protected: true}); await harness.background.handleCookieChange({removed: true, cause: 'expired', cookie: harness.targetCookie}); - await new Promise((resolve) => setTimeout(resolve, 190)); + await harness.flushRestoreTimers(); assert.equal(harness.calls.sets.length, 0); }); @@ -147,7 +162,7 @@ test('website expiry tombstones restore protected cookies', async () => { cause: 'expired_overwrite', cookie: harness.targetCookie, }); - await new Promise((resolve) => setTimeout(resolve, 190)); + await harness.flushRestoreTimers(); assert.equal(harness.calls.sets.length, 1); }); diff --git a/test/build.test.js b/test/build.test.js new file mode 100644 index 0000000..f5531b7 --- /dev/null +++ b/test/build.test.js @@ -0,0 +1,19 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const path = require('node:path'); +const {spawnSync} = require('node:child_process'); +const test = require('node:test'); + +const repositoryRoot = path.resolve(__dirname, '..'); + +test('build script rejects output paths outside the approved target directory', () => { + for (const output of ['..', 'src', repositoryRoot]) { + const result = spawnSync(process.execPath, ['scripts/build.mjs', 'chromium', output], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + assert.notEqual(result.status, 0, `unsafe output unexpectedly accepted: ${output}`); + assert.match(result.stderr, /Unsafe build output/); + } +}); diff --git a/test/core.test.js b/test/core.test.js index 0799cfc..4b1b955 100644 --- a/test/core.test.js +++ b/test/core.test.js @@ -161,10 +161,21 @@ test('site-specific filtering includes applicable parent-domain cookies only', ( ['host', 'parent']); }); +test('domain filtering observes DNS label boundaries', () => { + const cookies = [ + cookie({domain: 'example.com'}), + cookie({domain: 'sub.example.com', name: 'subdomain'}), + cookie({domain: 'notexample.com', name: 'substring'}), + ]; + assert.deepEqual(core.filterCookies(cookies, {domain: 'example.com'}).map((item) => item.name), + ['session', 'subdomain']); +}); + test('exact protection distinguishes path, store, host scope, and partition', () => { const rootCookie = cookie(); const pathCookie = cookie({path: '/app'}); const storeCookie = cookie({storeId: '1'}); + const domainCookie = cookie({domain: '.example.com', hostOnly: false}); const partitionedCookie = cookie({partitionKey: {topLevelSite: 'https://top.example'}}); const storage = { [core.protectionStorageKey(rootCookie)]: { @@ -176,6 +187,7 @@ test('exact protection distinguishes path, store, host scope, and partition', () assert.equal(core.isCookieProtected(rootCookie, protectedCookies), true); assert.equal(core.isCookieProtected(pathCookie, protectedCookies), false); assert.equal(core.isCookieProtected(storeCookie, protectedCookies), false); + assert.equal(core.isCookieProtected(domainCookie, protectedCookies), false); assert.equal(core.isCookieProtected(partitionedCookie, protectedCookies), false); }); @@ -252,7 +264,7 @@ test('expired persistent JSON cookies are rejected instead of becoming session c 'Send for raw': 'true', 'HTTP only raw': 'false', 'This domain only raw': 'true', - }), /expiration must be in the future/); + }), (error) => error.code === 'EXPIRED' && /expiration must be in the future/.test(error.message)); }); test('nameless cookies remain importable and protectable', () => { From fc800487eb7d68b0fd5ec327f02a33196dd89490 Mon Sep 17 00:00:00 2001 From: "mic (spark-01)" <85814106+q1@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:59:00 -0700 Subject: [PATCH 3/4] Avoid 32-bit epoch truncation --- src/api.js | 2 +- src/export.js | 2 +- test/repository.test.js | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/api.js b/src/api.js index 28a1606..4b3bd3a 100644 --- a/src/api.js +++ b/src/api.js @@ -279,7 +279,7 @@ vAPI.copy_cookies_to_store = async function(promise, store_id) { const cookies = await Promise.resolve(promise); const setPromises = cookies - .filter((cookie) => cookie.session || cookie.expirationDate > ((Date.now() / 1000 | 0) + 1)) + .filter((cookie) => cookie.session || cookie.expirationDate > (Math.floor(Date.now() / 1000) + 1)) .map((cookie) => vAPI.set_cookie(core.buildCookieSetDetails({...cookie, storeId: store_id}))); return vAPI.add_cookies(Promise.all(setPromises)); } diff --git a/src/export.js b/src/export.js index 8b3e92a..9679231 100644 --- a/src/export.js +++ b/src/export.js @@ -440,7 +440,7 @@ async function parseNETSCAPEFile(content) { const expirationDate = Number.parseInt(line[4], 10); if (!Number.isFinite(expirationDate)) throw new Error(`Invalid expiration date at line ${index + 1}.`); - if (expirationDate !== 0 && expirationDate <= ((Date.now() / 1000 | 0) + 1)) { + if (expirationDate !== 0 && expirationDate <= (Math.floor(Date.now() / 1000) + 1)) { expiredCount++; continue; } diff --git a/test/repository.test.js b/test/repository.test.js index 77b2a23..6d9eef6 100644 --- a/test/repository.test.js +++ b/test/repository.test.js @@ -44,3 +44,11 @@ test('runtime source no longer depends on a generated browser polyfill', () => { assert.equal(content.includes('core.js'), true, `${file} does not load core.js`); } }); + +test('runtime epoch comparisons do not truncate timestamps to signed 32-bit integers', () => { + for (const file of ['api.js', 'export.js']) { + const content = fs.readFileSync(path.join(root, 'src', file), 'utf8'); + assert.doesNotMatch(content, /Date\.now\(\)\s*\/\s*1000\s*\|\s*0/, + `${file} contains a Year 2038-unsafe epoch conversion`); + } +}); From 0866756c14ff6d37912e5b776fd7cc4935c0585c Mon Sep 17 00:00:00 2001 From: "mic (spark-01)" <85814106+q1@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:06:11 -0700 Subject: [PATCH 4/4] Avoid duplicate PR workflow runs --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff02f75..03b496a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,13 +2,15 @@ name: CI on: push: + branches: + - fpi pull_request: permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: