From 79a48a1a9c323c5b6c4bdfc386ec15bfe33962c2 Mon Sep 17 00:00:00 2001 From: user-github-me Date: Tue, 20 Oct 2026 10:03:02 +0800 Subject: [PATCH] Improve disclosure, backup reliability and security for v1.1.1 --- AGENTS.md | 4 +- CHANGELOG.md | 26 +++ CONTRIBUTING.md | 6 +- PRIVACY.md | 208 ++++++++++++------- README.md | 50 ++++- background.js | 219 ++++++++++++++++---- backup.js | 321 +++++++++++++++++++++++++++++ consent.css | 28 +++ consent.js | 86 ++++++++ dashboard.html | 6 +- dashboard.js | 34 +-- docs/SECURITY-STORAGE.md | 152 ++++++++++++++ docs/screenshots/banner.png | Bin 337761 -> 260681 bytes docs/screenshots/builder.png | Bin 134463 -> 123714 bytes docs/screenshots/collections.png | Bin 110661 -> 131399 bytes docs/screenshots/comparer.png | Bin 90406 -> 87967 bytes docs/screenshots/decoder.png | Bin 69674 -> 72993 bytes docs/screenshots/history.png | Bin 119754 -> 183252 bytes docs/screenshots/inspector.png | Bin 137273 -> 116745 bytes docs/screenshots/intercept.png | Bin 142983 -> 165451 bytes docs/screenshots/popup.png | Bin 42179 -> 43931 bytes docs/screenshots/repeater.png | Bin 155120 -> 164018 bytes docs/screenshots/runner.png | Bin 144921 -> 138134 bytes docs/screenshots/security.png | Bin 137796 -> 134919 bytes docs/screenshots/sitemap.png | Bin 100860 -> 174913 bytes docs/screenshots/websocket.png | Bin 82766 -> 154150 bytes docs/screenshots/workspace.png | Bin 126009 -> 144726 bytes icons/icon128.png | Bin 2019 -> 4845 bytes lab-ui.js | 83 +++++--- lab.css | 6 +- lab.js | 16 +- manifest.json | 11 +- package.json | 4 +- popup.css | 1 + popup.html | 3 + popup.js | 11 +- scripts/release-archive.py | 5 +- scripts/release.mjs | 15 +- tests/background-hardening.test.js | 183 ++++++++++++++++ tests/background.test.js | 11 +- tests/backup.test.js | 146 +++++++++++++ tests/browser-backup-large.mjs | 145 +++++++++++++ tests/browser-integration.mjs | 47 +++-- tests/browser-popup.mjs | 12 +- tests/browser-release-cases.mjs | 20 +- tests/consent.test.js | 40 ++++ tests/dashboard.test.js | 3 +- tests/lab-ui.test.js | 118 +++++++++++ tests/lab.test.js | 16 ++ tests/restore-race.test.js | 77 +++++++ workflow-ui.js | 3 +- 51 files changed, 1903 insertions(+), 213 deletions(-) create mode 100644 backup.js create mode 100644 consent.css create mode 100644 consent.js create mode 100644 docs/SECURITY-STORAGE.md create mode 100644 tests/background-hardening.test.js create mode 100644 tests/backup.test.js create mode 100644 tests/browser-backup-large.mjs create mode 100644 tests/consent.test.js create mode 100644 tests/lab-ui.test.js create mode 100644 tests/restore-race.test.js diff --git a/AGENTS.md b/AGENTS.md index dec7c2f..5307f34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,8 @@ CONTRIBUTING.md for scope, templates, and validation. - Never push changes directly to `main`; do not merge or publish without the user's instruction. -- Unpublished work for this upgrade stays in version `1.1.0`. Bump the version - for a subsequent release after this update is published. +- The published release is `1.1.0`; the approved follow-up targets `1.1.1`. + Keep related unpublished work in `1.1.1` until this update is published. - Keep the extension local, dependency-free, and compatible with Manifest V3. - Package runtime files and icons only, with `manifest.json` at the ZIP root. - Keep builds, store assets, signing keys, and private notes under ignored `local/`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 98a500d..c02a0d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 1.1.1 — 2026-10-20 + +### Added + +- First-use/update disclosure of automatic local traffic and credential storage, with affirmative continuation and existing-workspace recovery. +- Installed Help/privacy guidance for capture, Auto scope, active requests, debugger access, local storage and backups. +- Bounded chunked JSON-lines backups with optional authenticated AES-256-GCM encryption, progress, integrity verification and legacy JSON compatibility. +- A design proposal for password-protected local storage; the current database remains unencrypted. + +### Improved + +- Removed redundant `tabs` permission after browser checks of tab selection, Auto mode, workbench focus and HTTP/HTTPS/file attachment. +- Restricted messages and ports to expected extension pages, validated temporary header rules and tracked dashboard ownership across closure/restart. +- Clearer API Builder authentication fields and compact editors that leave more space for response assertions. +- Updated title, summary, privacy/permission guidance, workflow screenshots and recommended 128px icon padding. +- Release packaging derives its version from the manifest and includes new local runtime helpers. + +### Fixed + +- Local-file history no longer prevents reopening or restoring a workspace; active API requests remain HTTP(S) only. +- Backups above the former 200 MB ceiling round-trip in the bounded format, including encryption overhead. +- Import reads cannot race; canceling prevents late previews, and passwords clear after success or failure. +- Missing/revoked disclosure acceptance disables saved Auto and prevents silent restart after acknowledgment. +- Backup chunks yield through queued tasks, avoiding Chrome’s background timer throttling. +- Rule cleanup preserves other dashboards' active rules and removes orphaned rules on restart. + ## 1.1.0 — 2026-10-03 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d88608..0a54ad8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,9 +27,9 @@ Read [README.md](README.md), [PRIVACY.md](PRIVACY.md), and actually performed. Maintainer review and merge complete the issue. Do not push directly to `main`. Do not merge, tag a release, or publish to the -Chrome Web Store as part of a contribution. The pending upgrade stays at -**1.1.0** until it is published; follow the release version instructions in -[AGENTS.md](AGENTS.md). +Chrome Web Store as part of a contribution. The published release is **1.1.0**; +the approved next update is **1.1.1**. Keep related unpublished work at that +version and follow the release instructions in [AGENTS.md](AGENTS.md). ## Local setup diff --git a/PRIVACY.md b/PRIVACY.md index 8e817b1..e266cec 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,79 +1,145 @@ # Privacy Policy — Interceptor -_Last updated: 2026-10-03_ - -Interceptor is a browser extension for inspecting and modifying the HTTP traffic -of web pages you are testing. This policy explains what it does with data. - -## Short version - -**Interceptor does not collect, store remotely, sell, or send your data to the -developer. Traffic analysis happens locally in your browser.** Repeater, API -Builder, Runner, and credential comparisons send requests to the target you choose -when you start them. Security Review and imports send no requests. - -## What the extension accesses - -To do its job, Interceptor can read and modify the network requests and responses -of the tab you attach it to (or, in Auto mode, the tab or tabs you enable). This -can include URLs, headers (such as cookies), and request/response bodies. This -information is processed **on your device**, only to: - -- display traffic in the extension's own panel (HTTP history, intercept, repeater), -- apply the modifications you explicitly make or the Auto-mode rules you configure. -- inspect WebSocket frames, run response assertions, and review captured security evidence. - -## What is stored - -The extension automatically saves the workbench **locally on your own computer**, -in the browser profile’s IndexedDB database (`interceptor-workspace`). This covers: - -- captured/imported HTTP history and bounded request/response bodies; -- Repeater drafts, responses, five snapshots per tab, and assertions; -- Collections, notes, shared variables, and named environments; -- API Builder drafts and authentication fields, response previews, and test results; -- Runner plans/results, Comparer inputs, Decoder and Inspector drafts; +_Last updated: 2026-10-20_ + +Interceptor is a browser workbench for inspecting, reproducing, and testing HTTP +and WebSocket traffic for applications you own or are authorized to test. This +policy explains the data it handles and the controls you have over that data. + +## What happens to your testing data + +**The developer does not receive your testing data.** The extension collects +selected traffic for your testing workflow, processes it on your device, and +saves your work in your browser profile. There is no account, analytics, +telemetry, cloud synchronization, or developer-operated collection service. + +Repeater, API Builder, Runner, and credential comparisons send the requests you +start to the target servers you choose. Intercepted or automatically rewritten +page requests continue to their intended servers when you forward them or enable +Auto mode. Those servers receive the request data and may retain it under their +own policies. Passive Security Review, decoding, comparisons of existing text, +and file imports do not send testing requests. Restoring a backup sends no +requests and does not attach the debugger. + +## First use and help + +Before new testing begins, Interceptor displays a local-data notice that explains +automatic saving, credentials, target requests, and backup controls. Continuing +acknowledges that notice; it does not send a request or attach a tab. The Help +entry lets you revisit these explanations and this privacy policy. Existing work +is preserved when the notice appears. A pre-acceptance recovery download preserves +the saved record for recovery; if that record is damaged, the download does not +guarantee it can be restored without repair. It is unencrypted and can contain +credentials. + +## Information the extension accesses and uses + +You select which tab to attach for inspection, or which tab(s) Auto mode may +operate on. The tab selector uses open-tab titles and URLs so you can identify a +testing target. The extension can read and modify the selected traffic’s URLs, +headers, cookies, request and response bodies, and WebSocket frames. Local file +inspection can include file paths when browser file access is enabled. Authentication +fields, shared variables, and environments you enter can include usernames, +passwords, access tokens, API keys, and session cookies. + +Traffic, files, and drafts may contain personal information, website content, +form data, or other sensitive information depending on the applications you +choose to test and the data you enter or import. You decide which applications +and datasets to inspect. The extension does not use these data for advertising, +profiling, selling, or purposes unrelated to its debugging and testing tools. + +Data is used to display and search captured traffic, edit or reproduce requests, +apply your Auto-mode rules, run response assertions, inspect fields and frames, +compare results, review security evidence, and save or restore your testing work. +Security observations guide manual testing and do not establish a vulnerability. + +## Local storage + +The extension automatically saves the workbench in the browser profile’s local +IndexedDB database (`interceptor-workspace`). This includes: + +- captured and imported HTTP history with bounded request and response bodies; +- Repeater drafts, responses, up to five snapshots per tab, and assertions; +- Collections, request notes, shared variables, and named environments; +- API Builder drafts and authentication fields, response previews, and tests; +- Runner plans and results, Comparer inputs, Decoder and Inspector drafts; - Auto logs, security observations and review status, WebSocket frame previews; -- recoverable text from paused edits, without restarting those paused sessions. - -Settings, Auto-mode rules and pane-size preferences also use `chrome.storage.local`. -Pane sizes are included in full backups. Session tab IDs and temporary header -rule IDs use `chrome.storage.session`. Earlier saved drafts and -Collections are migrated when the workbench opens. No workspace data uses browser -sync or a remote server. Local records are protected by your computer/browser -profile access controls; the extension does not separately encrypt its database. - -The Workspace page downloads a full JSON backup and imports it after validation -and a restore preview. Backups include stored credentials. An optional password -encrypts backups with AES-256-GCM and a PBKDF2-SHA-256 key (200,000 iterations, -random salt and IV); backup passwords are never saved. Without a password, the -backup is readable JSON. Restoring replaces the current workspace, keeps a local -undo copy, leaves Auto mode off, and does not send traffic or attach a tab. - -Exporting a collection, HAR, or Runner CSV creates a file at your request. -Collection exports omit workspace variables, but raw requests can still contain -cookies or tokens. cURL and Postman imports are parsed as data; shell commands and -Postman scripts are never executed. Security reports hide known credential query -fields and cookie values in observation evidence. Captured raw traffic remains -available locally and is included in full backups. - -## What is NOT done - -- No data is sent to the developer, advertisers, or analytics services. -- No analytics, tracking, or telemetry of any kind. -- No accounts, and no remote code is loaded or executed. - -## Your control - -You choose which tab(s) the extension attaches to. Detaching, turning Auto mode -off, and stopping Runner or a request end those operations. Clearing history, -deleting requests/environments, and clearing results/frames removes those items -from the current workspace and its next local save. The previous-restore undo copy -and downloaded files are separate copies. Uninstalling the extension or deleting -its browser profile removes extension storage; backup files you downloaded remain -where you saved them. +- recoverable text from paused edits, without restarting paused sessions. + +Settings, Auto-mode rules, and pane-size preferences use `chrome.storage.local`. +Pane sizes are included in full backups. Session tab IDs and temporary request +header rule IDs use `chrome.storage.session`. Earlier saved drafts and collections +are migrated when the workbench opens. No testing workspace uses browser sync or +a remote database. + +The local database is **not separately encrypted by the extension**. Its records +rely on your computer and browser-profile access controls. Other people or software +with access to that profile may be able to read stored traffic and credentials. +Optional backup encryption protects the downloaded backup file, not the local +database or requests sent to testing targets. + +Requests use the transport of the target URL you choose. HTTPS encrypts the +connection to the target; HTTP does not. Use HTTPS for sensitive testing data and +keep intentional HTTP testing limited to appropriate test environments. + +## Backups, imports, and exports + +Workspace backups include the saved traffic, drafts, variables, authentication +fields, results, and preferences. Without a password, a full backup is readable +JSON lines with unencrypted encoded data that can expose stored credentials to +anyone who can access the file. The format processes 1 MiB chunks, with a 1 GB +workspace-JSON budget and a 1.4 GB file limit. Older plaintext JSON files remain +importable within the 1 GB budget; older encrypted envelopes have a 200 MB +compatibility limit. Device memory and browser quotas can impose lower practical +limits. Rejected imports do not replace your saved work. + +A password encrypts each downloaded backup chunk with AES-256-GCM and a +PBKDF2-SHA-256 key (200,000 iterations, random salt and per-chunk IV). Backup +passwords are never saved. +The same password is required to import an encrypted file. + +Imports are validated and previewed before replacement. Restoring replaces the +current workspace, keeps one local undo copy, leaves Auto mode off, and sends no +requests or debugger attachments. Restored drafts may still contain credentials. + +Collection, HAR, Runner CSV, security report, and WebSocket exports create files +when you request them. Collection exports omit workspace variables, but raw +requests can still include cookies and tokens. Security reports hide known +credential query fields and cookie values in observation evidence; this is not a +guarantee that all sensitive information is removed from every export. Full +backups and raw captured traffic retain the data you inspected. + +cURL and Postman imports are parsed as data. Shell commands and Postman scripts +are not executed. No remotely hosted code is loaded or executed by the extension. + +## Retention and deletion + +Saved testing work remains in your browser profile until you remove it, replace +it through a restore, uninstall the extension, or delete the browser profile. +Capture and preview limits may also remove older entries or truncate large +bodies and frames. + +Detach or disable Auto mode to stop those operations. Stop or cancel an active +request or Runner run to end it. Clear history, delete requests/environments, and +clear results or frames to remove those items from the current workspace and its +next local save. The previous-restore undo copy is a separate retained copy. +Downloaded files are also separate; deleting data inside the extension or +uninstalling it does not delete files you downloaded. Manage or delete those +files using your computer’s file controls. + +## Limited Use + +Interceptor’s use of information received from Chrome extension APIs adheres to +the Chrome Web Store User Data Policy, including its Limited Use requirements. +Testing data is used only for the extension’s disclosed debugging and API/security +inspection features. The developer does not receive it, use it for advertising, +sell it, or use it to determine creditworthiness or for lending. User-started +requests are sent to the testing targets you choose to provide these features. ## Contact Questions or concerns: open an issue at https://github.com/user-github-me/interceptor/issues + +Please remove credentials and personal data from screenshots, logs, requests, +backups, or other files before posting them publicly in an issue. diff --git a/README.md b/README.md index 58650cf..24c3296 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@

- Interceptor — a Burp-style HTTP proxy that lives in your browser + Interceptor — HTTP and API testing in your browser

See, edit, replay and auto-tamper the HTTP your web app sends — right inside your browser.
- A lightweight, Burp-style proxy as a Chromium extension. No proxy setup, no certificates, no external app. + A local HTTP and API testing workbench for Chromium. No system proxy setup or extra certificates.

@@ -18,7 +18,7 @@ ## What is this? -**Interceptor** is a browser extension for web developers and security testers who want to inspect and manipulate their own app's traffic without leaving the browser. It's the everyday 20% of Burp Suite — intercept, history, and repeater — plus one thing Burp makes you do by hand: **Auto mode**, which rewrites chosen fields (like a payment `amount`) on every request automatically, so you can test server-side validation in seconds. +**Interceptor** is a browser workbench for web developers and security testers to inspect, reproduce, and test their application's HTTP and WebSocket traffic. Capture and edit live messages, build API requests, run reusable response tests, review recorded security evidence, and keep the work locally. **Auto mode** rewrites selected fields on in-scope requests to help test server-side validation. Because it's built on the Chrome DevTools Protocol, it can truly **pause, edit, forward, and drop** live requests and responses — something a normal `webRequest` extension cannot do. @@ -28,7 +28,7 @@ Because it's built on the Chrome DevTools Protocol, it can truly **pause, edit, - 🧲 **Intercept** — pause requests (and responses) and edit the raw method, path, query, headers or body before they continue. Forward, drop, or forward-and-catch-the-response. - 📜 **HTTP History** — a live table of every request from the target tab, with full headers (including `Cookie`/`Set-Cookie`), bodies, timing and size. Search URLs, headers, and bodies; filter by method/status/type; import or export **HAR**. -- 🔁 **Repeater** — hand-craft a raw request and fire it as many times as you like. What you type is exactly what goes on the wire — including `Cookie`, `Origin`, `User-Agent` and `Sec-*` headers. +- 🔁 **Repeater** — hand-craft a raw request and fire it as many times as you like. Reproduce headers such as `Cookie`, `Origin`, `User-Agent` and `Sec-*`; inspect **Actual request sent** to check browser normalization and restrictions. - 📥 **cURL import** — paste a command from DevTools to create an editable Repeater request. Nothing executes or sends during import. - 🗂️ **Collections & variables** — organize named requests in folders with notes. Reuse `{{host}}`, `{{baseUrl}}`, or your own variables across Repeater and Runner. Import/export portable collections; work is saved locally automatically. - 🧪 **Runner** — replace a value with `{{payload}}`, try up to 50 values sequentially, and inspect status, timing, size, and response-text checks. Cancel a run, export CSV, or compare results. @@ -91,6 +91,16 @@ git clone https://github.com/user-github-me/interceptor.git ## Quick start +On first use or when the data notice changes, review the local-saving notice and +choose **Continue and save locally** before starting new testing. History, drafts, authentication +fields, and results save automatically in this browser profile. The local +database is not separately encrypted; optional encryption protects downloaded +backups. **Help** explains capture, Auto scope, active requests, the debugger +banner, and links to the [privacy policy](PRIVACY.md). Existing work is preserved. +The notice also offers a raw recovery download of existing saved work. Damaged +records may need repair before restore; recovery downloads are unencrypted and +can contain credentials. + ### Test a payment flow in 15 seconds (Auto mode) 1. Click the **Interceptor** toolbar icon. @@ -162,12 +172,15 @@ may be intentional. **WebSockets** records frames while Intercept has a tab attached; it does not pause or inject messages. All workbench data saves automatically in the current browser profile’s local -database, within the documented preview limits. Outgoing Builder, Repeater, +database, which is not separately encrypted by the extension, within the documented +preview limits. Outgoing Builder, Repeater, Runner and credential-comparison requests also appear in **HTTP History**. **Workspace → Download full backup** exports history, snapshots, collections, environments, results, tool -drafts, security observations, and frames. Set a password of at least eight -characters to encrypt it. To restore, enter the same password, choose **Import +drafts, security observations, and frames as a chunked JSON-lines file. Older JSON +backups can still be imported. Set a password of at least eight characters to +encrypt the downloaded chunks; the local database remains unencrypted. To restore, +enter the same password, choose **Import backup**, review the counts, then choose **Replace workspace**. Detach and finish active requests first. Restore sends no requests, leaves Auto mode off, and keeps a local undo copy. Backups can contain credentials; downloaded files remain after @@ -226,12 +239,11 @@ Use **URL safety scope** to constrain those rules. Each line can be plain text, | Permission | Why | |---|---| | `debugger` | The core: pause, edit, forward and drop live requests/responses via CDP. | -| `tabs` | List tabs to target and coordinate attach/detach. | | `storage` | Save local settings/rules and session coordination; the complete workspace is saved locally in IndexedDB. | | `webRequest` | Show the *"Actual request sent"* view in Repeater. | | `declarativeNetRequestWithHostAccess` | Let Repeater send otherwise-forbidden headers (`Cookie`, `Origin`, `User-Agent`, …) exactly as typed. | | `clipboardWrite` | Copy raw messages, cURL commands, decoded output, and diffs when you click a copy button. | -| `` | Access traffic from the sites you select and send your API tests to their chosen targets. | +| `` | Access selected testing sites and HTTP(S) API targets; show matching tab titles/URLs and inspect allowed local-file tabs. | There are no analytics or telemetry. Analysis and storage are local; active API tools send requests only to the targets you choose. @@ -242,9 +254,17 @@ There are no analytics or telemetry. Analysis and storage are local; active API - Bodies are edited as UTF-8 text; binary and oversized bodies are passed through unchanged. History keeps up to 2,500 entries, 750,000 characters per body, and a 64-million-character total text budget. Repeater previews up to 4 MB per response. - Raw panes render up to 200,000 characters to keep large responses responsive while resizing. Copy and exports retain the stored text within each tool's capture limits. - Workspaces save automatically in the browser profile on your PC. Repeater supports 100 tabs; Collections 100 requests; environments 50; WebSockets 1,000 capped frames. Browser disk quotas still apply; the UI reports save failures and offers backup download. +- Full backups process 1 MiB chunks and accept up to 1 GB of workspace JSON within + a 1.4 GB file limit. Legacy plaintext JSON imports use the same 1 GB data budget; + legacy encrypted envelopes retain a 200 MB compatibility limit. Oversized or + invalid exports/imports report an error without replacing saved work. Device + memory and browser quotas can impose lower practical limits. - Runner is limited to 50 payloads and keeps capped response previews. Site Map and passive Security Review cover recorded/imported traffic; they do not crawl sites or prove vulnerabilities automatically. - cURL import supports literal URLs, method, headers, body, cookies, Basic auth, and redirects. Unsupported options and file uploads are rejected instead of silently omitted. - Browsers can't send a body with `GET`/`HEAD`, so Repeater can't either. +- Local `file:` documents can be recorded when the browser allows file access for + the extension. Their history survives workspace reload and backup restore; active + API tools send only HTTP(S) requests. - Traffic from other-process iframes and some service workers may not be captured. - For a self-signed HTTPS dev cert, open the URL in a tab and accept it once before using Repeater. @@ -267,7 +287,8 @@ npm run release:verify The signed build requires your existing signing key and previous local CRX; it does not create a replacement key. Outputs stay under ignored -`local/release-1.1.0/`. ZIP and CRX payloads contain the same runtime files; the +`local/release-1.1.1/` for this update. ZIP and CRX payloads contain the same runtime +files; the verification report checks signatures, identity continuity, content and checksums. The browser integration check uses a local echo server and an isolated Chromium @@ -276,6 +297,7 @@ profile with the unpacked extension loaded and remote debugging enabled: ```bash node tests/browser-integration.mjs http://127.0.0.1:9226 node tests/browser-popup.mjs http://127.0.0.1:9226 --screenshots +node tests/browser-backup-large.mjs http://127.0.0.1:9226 --require-file-access ``` It checks debugger attachment, request/response edits, Auto scope and worker @@ -288,6 +310,12 @@ themes at narrow widths, auth/body modes, exports, Postman import, storage failu encrypted file import, and restore undo. Add `--screenshots --store-screenshots` to create local workbench captures and five 1280×800 store images. +The separate large-backup check uses synthetic 51 × 4 MB response drafts, +measures export/import responsiveness, and checks legacy backup recovery and +local-file capture/restore. It restores the isolated profile's previous workspace. +Enable file access for that complete check; it can use `--extension-id ID` when +the extension worker is idle and no dashboard is open. + ``` manifest.json MV3 manifest background.js service worker — Auto mode engine + tab coordination @@ -300,6 +328,8 @@ workflow-ui.js/.css collections, Inspector, Site Map, Runner, and tool switcher lab.js assertions, passive review, Postman import, backup validation/encryption lab-ui.js/.css API Builder, Security Review, WebSockets, environments, backup UI local-store.js IndexedDB storage for complete local workspaces +consent.js/.css revisioned local-data disclosure and in-product help +backup.js bounded chunked JSON-lines backup import/export tests/ Node tests for parsing, rewriting, scope, HAR, diff, and codecs icons/ extension icons docs/screenshots/ images used in this README diff --git a/background.js b/background.js index 4318c07..d223a45 100644 --- a/background.js +++ b/background.js @@ -10,8 +10,8 @@ * attach/detach/claim/release operations run through one serial queue so a * settings-triggered sync can't re-attach a tab the dashboard just claimed. */ -importScripts('http.js'); -/* global HTTP */ +importScripts('http.js', 'consent.js'); +/* global HTTP, Disclosure */ const S = { autoMode: false, @@ -23,9 +23,11 @@ const S = { }; const attached = new Set(); // tabIds this worker attached for Auto mode const repeaterRuleIds = new Set(); // temporary DNR rules owned by an open dashboard +const repeaterRuleOwners = new Map(); // rule id -> dashboard tab id let dashboardTabId = null; // tab currently owned by the dashboard (excluded) let autoCount = 0; -let dashboardPorts = 0; +const dashboardPorts = new Map(); // open ports per dashboard tab +let disclosureAccepted = false; let repeaterChain = Promise.resolve(); function serializeRepeater(fn) { const run = repeaterChain.then(fn, fn); @@ -43,13 +45,18 @@ function serial(fn) { // ---- state ---- async function loadState() { + disclosureAccepted = await Disclosure.accepted(); try { const [{ settings }, session] = await Promise.all([ chrome.storage.local.get('settings'), chrome.storage.session.get('autoTabId'), ]); const s = settings || {}; - S.autoMode = !!s.autoMode; + // An update or revoked disclosure must not restart a saved Auto session. + S.autoMode = disclosureAccepted && !!s.autoMode; + if (!disclosureAccepted && s.autoMode) { + await chrome.storage.local.set({ settings: { ...s, autoMode: false } }); + } S.autoScope = s.autoScope === 'tab' ? 'tab' : 'all'; S.autoTabId = typeof session.autoTabId === 'number' ? session.autoTabId : null; S.autoRules = Array.isArray(s.autoRules) @@ -62,7 +69,7 @@ async function loadState() { const names = () => S.autoRules.flatMap((r) => HTTP.splitNames(r && r.param)); const scopeErrors = () => HTTP.urlInScope('https://interceptor.invalid/', S.autoInclude, S.autoExclude).errors; -const isOn = () => S.autoMode && names().length > 0 && !scopeErrors().length && (S.autoScope === 'all' || S.autoTabId != null); +const isOn = () => disclosureAccepted && S.autoMode && names().length > 0 && !scopeErrors().length && (S.autoScope === 'all' || S.autoTabId != null); const shouldHandle = (tabId) => tabId !== dashboardTabId && (S.autoScope === 'all' || (S.autoScope === 'tab' && tabId === S.autoTabId)); @@ -79,26 +86,44 @@ let ready = serial(init); async function init() { await loadState(); try { - const sess = await chrome.storage.session.get(['dashboardTabId', 'attachedTabs', 'autoCount', 'repeaterRuleIds']); + const sess = await chrome.storage.session.get(['dashboardTabId', 'attachedTabs', 'autoCount', 'repeaterRuleIds', 'repeaterRuleOwners']); dashboardTabId = sess.dashboardTabId ?? null; if (typeof sess.autoCount === 'number') autoCount = sess.autoCount; const targets = await chrome.debugger.getTargets(); const live = new Set(targets.filter((t) => t.attached && t.tabId != null).map((t) => t.tabId)); for (const id of (sess.attachedTabs || [])) if (live.has(id)) attached.add(id); - for (const id of (sess.repeaterRuleIds || [])) if (Number.isInteger(id)) repeaterRuleIds.add(id); + for (const id of (sess.repeaterRuleIds || [])) { + if (!validRuleId(id)) continue; + repeaterRuleIds.add(id); + const owner = sess.repeaterRuleOwners?.[id]; + if (validTabId(owner)) repeaterRuleOwners.set(id, owner); + } } catch { /* ignore */ } + // Recover browser-installed rules even if the worker stopped before saving + // their ownership metadata, or an older dashboard cleared that metadata. + try { + const liveRules = await chrome.declarativeNetRequest.getSessionRules(); + for (const rule of liveRules) if (validRuleId(rule.id)) repeaterRuleIds.add(rule.id); + } catch { /* tracked rules can still be cleaned with existing metadata */ } // A dashboard may have closed while this worker was asleep. const dashboards = await chrome.tabs.query({ url: chrome.runtime.getURL('dashboard.html') }); if (!dashboards.length) { dashboardTabId = null; await chrome.storage.session.set({ dashboardTabId: null }); await clearRepeaterRules(); + } else { + const liveDashboards = new Set(dashboards.map((tab) => tab.id)); + await serializeRepeater(async () => { + for (const id of [...repeaterRuleIds]) { + if (!disclosureAccepted || !liveDashboards.has(repeaterRuleOwners.get(id))) await removeRepeaterRuleNow(id); + } + }); } await syncNow(); } async function attachRaw(tabId) { - if (attached.has(tabId)) return; + if (attached.has(tabId) || !await Disclosure.accepted()) return; try { await chrome.debugger.attach({ tabId }, '1.3'); } catch { return; } @@ -155,7 +180,9 @@ async function onPaused(source, params) { if (id == null || id === dashboardTabId) return; // dashboard handles its own tab const req = params.request; try { - if (isOn() && shouldHandle(id) && autoInScope(req.url)) { + // Recheck the persisted choice before handling traffic, including revocation + // while a storage-change sync is queued behind another debugger operation. + if (isOn() && await Disclosure.accepted() && shouldHandle(id) && autoInScope(req.url)) { const rb = HTTP.requestBodyText(req); const canRewriteBody = rb.known && rb.text.length <= 750_000; const textBody = canRewriteBody ? rb.text : ''; @@ -193,7 +220,16 @@ function broadcast(method, mod, tabId) { } function saveRepeaterRules() { - return chrome.storage.session.set({ repeaterRuleIds: [...repeaterRuleIds] }).catch(() => {}); + return chrome.storage.session.set({ + repeaterRuleIds: [...repeaterRuleIds], repeaterRuleOwners: Object.fromEntries(repeaterRuleOwners), + }).catch(() => {}); +} + +async function removeRepeaterRuleNow(id) { + await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [id] }); + repeaterRuleIds.delete(id); + repeaterRuleOwners.delete(id); + await saveRepeaterRules(); } function clearRepeaterRules() { @@ -201,19 +237,31 @@ function clearRepeaterRules() { } async function clearRepeaterRulesNow() { const ids = [...repeaterRuleIds]; - repeaterRuleIds.clear(); - await saveRepeaterRules(); if (ids.length) { - try { await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: ids }); } catch { /* already gone */ } + try { await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: ids }); } + catch { return; } // keep ownership so a later cleanup/restart can retry } + repeaterRuleIds.clear(); + repeaterRuleOwners.clear(); + await saveRepeaterRules(); } chrome.runtime.onConnect.addListener((port) => { - if (port.name !== 'dashboard') return; - dashboardPorts++; + if (port.name !== 'dashboard' || senderPage(port.sender) !== 'dashboard' || !validTabId(port.sender.tab?.id)) { + port.disconnect(); + return; + } + const tabId = port.sender.tab.id; + dashboardPorts.set(tabId, (dashboardPorts.get(tabId) || 0) + 1); port.onDisconnect.addListener(() => { - dashboardPorts = Math.max(0, dashboardPorts - 1); - if (!dashboardPorts) clearRepeaterRules(); + const remaining = (dashboardPorts.get(tabId) || 1) - 1; + if (remaining) dashboardPorts.set(tabId, remaining); + else { + dashboardPorts.delete(tabId); + serializeRepeater(async () => { + for (const [id, owner] of repeaterRuleOwners) if (owner === tabId) await removeRepeaterRuleNow(id); + }).catch(() => {}); // retained ownership allows startup cleanup to retry + } }); }); @@ -238,7 +286,12 @@ chrome.debugger.onDetach.addListener((source, reason) => { }); chrome.storage.onChanged.addListener((changes, area) => { - if (area === 'local' && changes.settings) sync(); + if (area === 'local' && (changes.settings || changes.localDataDisclosure)) { + serial(async () => { + await syncNow(); + if (!disclosureAccepted) await clearRepeaterRules(); + }); + } if (area === 'session' && changes.dashboardTabId) { serial(async () => { dashboardTabId = changes.dashboardTabId.newValue ?? null; await syncNow(); }); } @@ -260,10 +313,100 @@ chrome.tabs.onRemoved.addListener((tabId) => { } }); +const validTabId = (id) => Number.isSafeInteger(id) && id > 0; +const validRuleId = (id) => Number.isSafeInteger(id) && id >= 1000 && id <= 100999; +function senderPage(sender) { + if (sender?.id !== chrome.runtime.id || (sender.frameId != null && sender.frameId !== 0)) return null; + for (const page of ['dashboard', 'popup']) { + if (sender.url === chrome.runtime.getURL(`${page}.html`)) return page; + } + return null; +} +function exactKeys(value, required, optional = []) { + return value && typeof value === 'object' && !Array.isArray(value) + && required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) + && Object.keys(value).every((key) => required.includes(key) || optional.includes(key)); +} +const REQUEST_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'connect']); +const SET_HEADERS = new Set(['cookie', 'origin', 'referer', 'user-agent', 'accept-encoding', 'dnt', 'date', 'via', 'accept-charset', 'access-control-request-headers', 'access-control-request-method']); +const REMOVE_HEADERS = new Set(['cookie', 'origin', 'referer', 'user-agent', 'accept-language', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform']); +const escapeRuleUrl = (url) => url.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&'); + +/** Accept only the per-request header rule the dashboard generates. */ +function validateRepeaterRule(rule, tabId) { + if (!exactKeys(rule, ['id', 'priority', 'action', 'condition']) || !validRuleId(rule.id) || rule.priority !== 1) { + throw new Error('Invalid temporary header rule.'); + } + const { action, condition } = rule; + if (!exactKeys(action, ['type', 'requestHeaders']) || action.type !== 'modifyHeaders' + || !Array.isArray(action.requestHeaders) || !action.requestHeaders.length || action.requestHeaders.length > 256) { + throw new Error('Only request header changes are allowed.'); + } + const usedHeaders = new Set(); + for (const header of action.requestHeaders) { + if (!exactKeys(header, ['header', 'operation'], ['value']) || typeof header.header !== 'string' + || !/^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(header.header) || usedHeaders.has(header.header)) { + throw new Error('Invalid or duplicate request header.'); + } + usedHeaders.add(header.header); + if (header.operation === 'set') { + if (!(SET_HEADERS.has(header.header) || header.header.startsWith('sec-') || header.header.startsWith('proxy-')) + || typeof header.value !== 'string' || header.value.length > 500_000 || /[\r\n\0]/.test(header.value)) { + throw new Error('Invalid request header override.'); + } + } else if (header.operation !== 'remove' || !REMOVE_HEADERS.has(header.header) || 'value' in header) { + throw new Error('Invalid request header removal.'); + } + } + if (!exactKeys(condition, ['tabIds', 'resourceTypes'], ['requestMethods', 'urlFilter', 'regexFilter']) + || !Array.isArray(condition.tabIds) || condition.tabIds.length !== 1 || condition.tabIds[0] !== tabId + || !Array.isArray(condition.resourceTypes) || condition.resourceTypes.length !== 1 || condition.resourceTypes[0] !== 'xmlhttprequest' + || ('urlFilter' in condition) === ('regexFilter' in condition)) { + throw new Error('Header rules must target this dashboard request only.'); + } + if ('requestMethods' in condition && (!Array.isArray(condition.requestMethods) || condition.requestMethods.length !== 1 + || !REQUEST_METHODS.has(condition.requestMethods[0]))) throw new Error('Invalid request method scope.'); + let url; + if ('urlFilter' in condition) { + if (typeof condition.urlFilter !== 'string' || !condition.urlFilter.startsWith('|') || !condition.urlFilter.endsWith('|')) { + throw new Error('Header rules require an exact request URL.'); + } + url = condition.urlFilter.slice(1, -1); + if (/[*^|]/.test(url)) throw new Error('Header rules cannot match URL patterns.'); + } else { + if (typeof condition.regexFilter !== 'string' || !condition.regexFilter.startsWith('^') || !condition.regexFilter.endsWith('$')) { + throw new Error('Header rules require an exact request URL.'); + } + url = condition.regexFilter.slice(1, -1).replace(/\\(.)/g, '$1'); + if (condition.regexFilter !== '^' + escapeRuleUrl(url) + '$') throw new Error('Header rules cannot match URL patterns.'); + } + let parsed; + try { parsed = new URL(url); } catch { throw new Error('Invalid header rule URL.'); } + if (url.length > 200_000 || !/^https?:$/.test(parsed.protocol) || parsed.href !== url) { + throw new Error('Header rules require an HTTP(S) request URL.'); + } +} + +const MESSAGE_KEYS = { + claimTab: ['type', 'tabId'], releaseTab: ['type', 'tabId'], getAutoStatus: ['type'], + setRepeaterRule: ['type', 'rule'], removeRepeaterRule: ['type', 'ruleId'], + incrementAutoCount: ['type'], resetAutoCount: ['type'], +}; + chrome.runtime.onMessage.addListener((msg, sender, reply) => { (async () => { + const page = senderPage(sender); + const keys = MESSAGE_KEYS[msg?.type]; + if (!page || !keys || !exactKeys(msg, keys) || (msg.type !== 'getAutoStatus' && (page !== 'dashboard' || !validTabId(sender.tab?.id)))) { + throw new Error('This action is restricted to Interceptor pages.'); + } + if (['claimTab', 'releaseTab'].includes(msg.type) && !validTabId(msg.tabId)) throw new Error('Invalid target tab.'); + if (msg.type === 'removeRepeaterRule' && !validRuleId(msg.ruleId)) throw new Error('Invalid temporary header rule.'); await ready; - if (msg && msg.type === 'claimTab' && typeof msg.tabId === 'number') { + if (['claimTab', 'setRepeaterRule', 'incrementAutoCount'].includes(msg.type) && !await Disclosure.accepted()) { + throw new Error('Review and accept local data handling before starting a test.'); + } + if (msg.type === 'claimTab') { // Serialize so any in-flight sync finishes first, then we release the tab // and mark it owned — a later sync will exclude it. await serial(async () => { @@ -272,7 +415,7 @@ chrome.runtime.onMessage.addListener((msg, sender, reply) => { await detachRaw(msg.tabId); }); reply({ ok: true }); - } else if (msg && msg.type === 'releaseTab') { + } else if (msg.type === 'releaseTab') { await serial(async () => { if (dashboardTabId === msg.tabId) { dashboardTabId = null; @@ -281,51 +424,57 @@ chrome.runtime.onMessage.addListener((msg, sender, reply) => { await syncNow(); }); reply({ ok: true }); - } else if (msg && msg.type === 'getAutoStatus') { + } else if (msg.type === 'getAutoStatus') { await loadState(); reply({ on: isOn(), scope: S.autoScope, tabId: S.autoTabId, count: autoCount, attached: [...attached], include: S.autoInclude, exclude: S.autoExclude, scopeErrors: scopeErrors(), }); - } else if (msg && msg.type === 'setRepeaterRule' && msg.rule && Number.isInteger(msg.rule.id)) { + } else if (msg.type === 'setRepeaterRule') { try { await serializeRepeater(async () => { - const tabId = sender.tab?.id; - const tab = tabId == null ? null : await chrome.tabs.get(tabId); + const tabId = sender.tab.id; + validateRepeaterRule(msg.rule, tabId); + const previousOwner = repeaterRuleOwners.get(msg.rule.id); + if (previousOwner != null && previousOwner !== tabId) throw new Error('Header rule belongs to another dashboard.'); + const tab = await chrome.tabs.get(tabId); if (tab?.url !== chrome.runtime.getURL('dashboard.html')) throw new Error('Repeater dashboard is closed.'); repeaterRuleIds.add(msg.rule.id); + repeaterRuleOwners.set(msg.rule.id, tabId); await saveRepeaterRules(); await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [msg.rule.id], addRules: [msg.rule] }); // A close event can arrive while the browser installs the rule. - try { await chrome.tabs.get(tabId); } + try { + const live = await chrome.tabs.get(tabId); + if (live.url !== chrome.runtime.getURL('dashboard.html') || !await Disclosure.accepted()) throw new Error('Dashboard unavailable.'); + } catch { - await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [msg.rule.id] }); - repeaterRuleIds.delete(msg.rule.id); - await saveRepeaterRules(); + await removeRepeaterRuleNow(msg.rule.id); throw new Error('Repeater dashboard is closed.'); } }); reply({ ok: true }); } catch (error) { reply({ ok: false, error: error.message }); } - } else if (msg && msg.type === 'removeRepeaterRule' && Number.isInteger(msg.ruleId)) { + } else if (msg.type === 'removeRepeaterRule') { await serializeRepeater(async () => { - await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [msg.ruleId] }); - repeaterRuleIds.delete(msg.ruleId); - await saveRepeaterRules(); + const owner = repeaterRuleOwners.get(msg.ruleId); + if (owner != null && owner !== sender.tab.id) throw new Error('Header rule belongs to another dashboard.'); + // Removing a missing own rule is safe and supports failure cleanup. + if (owner != null) await removeRepeaterRuleNow(msg.ruleId); }); reply({ ok: true }); - } else if (msg && msg.type === 'incrementAutoCount') { + } else if (msg.type === 'incrementAutoCount') { autoCount++; await saveCount(); reply({ ok: true, count: autoCount }); - } else if (msg && msg.type === 'resetAutoCount') { + } else if (msg.type === 'resetAutoCount') { autoCount = 0; await saveCount(); reply({ ok: true, count: autoCount }); } else { reply({}); } - })(); + })().catch((error) => reply({ ok: false, error: error.message })); return true; // async response }); diff --git a/backup.js b/backup.js new file mode 100644 index 0000000..b8c12b8 --- /dev/null +++ b/backup.js @@ -0,0 +1,321 @@ +'use strict'; +/* Bounded, local full-workspace backup I/O. No scripts or network access. */ +(function (global) { + const HTTP = global.HTTP || (typeof require !== 'undefined' ? require('./http.js') : null); + const Lab = global.Lab || (typeof require !== 'undefined' ? require('./lab.js') : null); + const CHUNK_BYTES = 1_048_576; + const MAX_JSON_BYTES = 1_000_000_000; + const MAX_FILE_BYTES = 1_400_000_000; + const MAX_LEGACY_ENCRYPTED_BYTES = 200_000_000; + const MAX_NODES = 1_000_000; + const MAX_DEPTH = 100; + const MAX_STRING_CHARS = 16_000_000; + const encoder = new TextEncoder(); + // Timer callbacks are clamped to one second in background tabs. MessageChannel + // yields between chunks without turning a 204 MB backup into a minutes-long + // operation, and still lets input/rendering tasks run between data chunks. + let yieldChannel; + const yieldQueue = []; + function pause() { + if (typeof window === 'undefined' || typeof MessageChannel === 'undefined') return new Promise((resolve) => setTimeout(resolve, 0)); + if (!yieldChannel) { + yieldChannel = new MessageChannel(); + yieldChannel.port1.onmessage = () => yieldQueue.shift()?.(); + yieldChannel.port1.unref?.(); yieldChannel.port2.unref?.(); + } + return new Promise((resolve) => { yieldQueue.push(resolve); yieldChannel.port2.postMessage(null); }); + } + const fail = (text) => { throw new Error(text); }; + const budgetError = () => fail('Full backups support up to 1 GB of workspace JSON. Export individual history/collections too; no workspace data was changed.'); + + // Only one scalar token is buffered. Containers are built directly into a new + // object, without a full-file string or JSON.parse of the entire workspace. + class JsonReader { + constructor(options = {}) { this.stack = []; this.root = undefined; this.hasRoot = false; this.nodes = 0; this.token = null; this.legacyEncryptedBytes = options.legacyEncryptedBytes || 0; } + add(value) { + if (++this.nodes > MAX_NODES) fail('Backup has too many values (maximum 1,000,000).'); + const frame = this.stack.at(-1); + if (!frame) { + if (this.hasRoot) fail('Unexpected data after backup JSON.'); + this.root = value; this.hasRoot = true; return; + } + if (frame.type === 'array') { + if (!['value', 'value-or-end'].includes(frame.state)) fail('Invalid backup JSON array.'); + if (frame.value.length >= 10_000) fail('Backup array exceeds 10,000 entries.'); + frame.value.push(value); + } else { + if (frame.state !== 'value') fail('Invalid backup JSON object.'); + // __proto__ is an ordinary own property, never an object setter. + Object.defineProperty(frame.value, frame.key, { value, enumerable: true, writable: true, configurable: true }); + } + frame.state = 'comma-or-end'; + } + feed(text) { + let i = 0; + while (i < text.length) { + if (this.token?.type === 'string') { + const token = this.token; + let end = i; + while (end < text.length) { + const ch = text[end++]; + if (token.escape) { token.escape = false; continue; } + if (ch === '\\') { token.escape = true; continue; } + if (ch === '"') { token.closed = true; break; } + } + const part = text.slice(i, end); token.parts.push(part); token.length += part.length; i = end; + if (token.length > (token.key ? 120_002 : token.limit * 6 + 2)) fail('Backup JSON string is too large.'); + if (!token.closed) continue; + const value = JSON.parse(token.parts.join('')); + if (value.length > (token.key ? 20_000 : token.limit)) fail('Backup string value is too large.'); + this.token = null; + if (token.key) { + const frame = this.stack.at(-1); + if (frame.keys.has(value)) fail('Duplicate property in backup JSON.'); + if (frame.keys.size >= 10_000) fail('Backup object has too many properties.'); + frame.keys.add(value); frame.key = value; frame.state = 'colon'; + } else this.add(value); + continue; + } + if (this.token) { + const token = this.token; + const match = /^[A-Za-z0-9+\-.]*/.exec(text.slice(i))[0]; + token.parts.push(match); token.length += match.length; i += match.length; + if (token.length > 1000) fail('Invalid numeric or literal backup JSON token.'); + if (i === text.length) continue; + this.finishToken(); continue; + } + const ch = text[i]; + if (/\s/.test(ch)) { if (!/[ \t\r\n]/.test(ch)) fail('Invalid JSON whitespace.'); i++; continue; } + const frame = this.stack.at(-1); + if (frame?.state === 'colon') { if (ch !== ':') fail('Missing colon in backup JSON.'); frame.state = 'value'; i++; continue; } + if (frame?.state === 'comma-or-end') { + if (ch === ',') { frame.state = frame.type === 'object' ? 'key' : 'value'; i++; continue; } + if (ch === (frame.type === 'object' ? '}' : ']')) { this.stack.pop(); i++; continue; } + fail('Missing comma in backup JSON.'); + } + if (frame?.type === 'object' && ['key', 'key-or-end'].includes(frame.state)) { + if (ch === '}' && frame.state === 'key-or-end') { this.stack.pop(); i++; continue; } + if (ch !== '"') fail('Invalid property name in backup JSON.'); + this.token = { type: 'string', key: true, parts: ['"'], length: 1, escape: false }; i++; continue; + } + if (frame?.type === 'array' && frame.state === 'value-or-end' && ch === ']') { this.stack.pop(); i++; continue; } + if (!frame && this.hasRoot) fail('Unexpected data after backup JSON.'); + if (ch === '{' || ch === '[') { + if (this.stack.length >= MAX_DEPTH) fail('Backup JSON is nested too deeply.'); + const value = ch === '{' ? {} : []; this.add(value); + this.stack.push(ch === '{' ? { type: 'object', value, keys: new Set(), state: 'key-or-end' } : { type: 'array', value, state: 'value-or-end' }); i++; continue; + } + if (ch === '"') { + const legacyData = this.stack.length === 1 && frame?.type === 'object' && frame.key === 'data' && this.root.format === 'interceptor-encrypted-backup'; + this.token = { type: 'string', key: false, parts: ['"'], length: 1, escape: false, limit: legacyData ? this.legacyEncryptedBytes || MAX_STRING_CHARS : MAX_STRING_CHARS }; i++; continue; + } + if (/[\-0-9tfn]/.test(ch)) { this.token = { type: 'primitive', parts: [], length: 0 }; continue; } + fail('Invalid backup JSON.'); + } + } + finishToken() { + const value = JSON.parse(this.token.parts.join('')); + if (typeof value === 'number' && !Number.isFinite(value)) fail('Invalid non-finite number in backup.'); + this.token = null; this.add(value); + } + finish() { + if (this.token?.type === 'primitive') this.finishToken(); + if (this.token || this.stack.length || !this.hasRoot) fail('Backup JSON is incomplete.'); + return this.root; + } + } + + // Generate bounded string pieces, preserving JSON's ordinary undefined/null + // handling. Sources are local snapshots or validated imported objects. + function* jsonPieces(value, depth = 0, seen = new Set(), counter = { nodes: 0 }) { + if (++counter.nodes > MAX_NODES) fail('Backup has too many values (maximum 1,000,000).'); + if (typeof value === 'string') { + if (value.length > MAX_STRING_CHARS) fail('Backup string value is too large.'); + yield '"'; + for (let i = 0; i < value.length; i += 8192) yield JSON.stringify(value.slice(i, i + 8192)).slice(1, -1); + yield '"'; return; + } + if (value == null || typeof value === 'boolean' || typeof value === 'number') { yield JSON.stringify(value); return; } + if (typeof value !== 'object') fail('Unsupported value in backup.'); + if (depth >= MAX_DEPTH || seen.has(value)) fail('Backup contains a cycle or is nested too deeply.'); + seen.add(value); + const array = Array.isArray(value), keys = array ? Array.from({ length: value.length }, (_, i) => i) : Object.keys(value).filter((key) => value[key] !== undefined); + if (keys.length > 10_000) fail('Backup container exceeds 10,000 entries.'); + yield array ? '[' : '{'; let first = true; + for (const key of keys) { + if (!first) yield ','; first = false; + if (!array) { if (key.length > 20_000) fail('Backup property name is too large.'); yield JSON.stringify(key); yield ':'; } + yield* jsonPieces(array && value[key] === undefined ? null : value[key], depth + 1, seen, counter); + } + yield array ? ']' : '}'; seen.delete(value); + } + async function* jsonChunks(value) { + let buffer = new Uint8Array(CHUNK_BYTES), offset = 0, total = 0; + for (const piece of jsonPieces(value)) { + const bytes = encoder.encode(piece); + let position = 0; + while (position < bytes.length) { + const count = Math.min(bytes.length - position, CHUNK_BYTES - offset); + buffer.set(bytes.subarray(position, position + count), offset); offset += count; position += count; total += count; + if (total > MAX_JSON_BYTES) budgetError(); + if (offset === CHUNK_BYTES) { yield buffer; buffer = new Uint8Array(CHUNK_BYTES); offset = 0; } + } + } + if (offset) yield buffer.subarray(0, offset); + } + function decode64(text, maxBytes) { + if (typeof text !== 'string' || !text.length || text.length > 4 * Math.ceil(maxBytes / 3) || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(text)) fail('Invalid backup Base64 encoding.'); + const bytes = HTTP.b64ToBytes(text); + if (bytes.length > maxBytes || HTTP.bytesToB64(bytes) !== text) fail('Invalid backup Base64 encoding.'); + return bytes; + } + function ivFor(nonce, index) { + const iv = new Uint8Array(12); iv.set(nonce); new DataView(iv.buffer).setUint32(8, index); return iv; + } + async function keyFor(password, salt) { + if (typeof password !== 'string' || password.length < 8) fail('Use a backup password with at least 8 characters.'); + const key = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveKey']); + return crypto.subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 200_000 }, key, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + } + async function chain(previous, bytes) { + const source = new Uint8Array(previous.length + bytes.length); source.set(previous); source.set(bytes, previous.length); + return new Uint8Array(await crypto.subtle.digest('SHA-256', source)); + } + const hex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + const aadFor = (headerLine, kind, index) => encoder.encode(headerLine + '\n' + kind + ':' + index); + async function encodeBlob(backup, password, options) { + const encrypted = !!password; + const header = { format: 'interceptor-backup-stream', version: 2, encoding: 'base64', chunkBytes: CHUNK_BYTES, encrypted }; + let key, nonce; + if (encrypted) { + const salt = crypto.getRandomValues(new Uint8Array(16)); nonce = crypto.getRandomValues(new Uint8Array(8)); + header.salt = HTTP.bytesToB64(salt); header.nonce = HTTP.bytesToB64(nonce); header.iterations = 200_000; + key = await keyFor(password, salt); + } + const headerLine = JSON.stringify(header), parts = [new Blob([headerLine + '\n'])]; + let fileBytes = parts[0].size, count = 0, total = 0, hash = new Uint8Array(32); + const append = (line) => { const part = new Blob([line + '\n']); fileBytes += part.size; if (fileBytes > MAX_FILE_BYTES) budgetError(); parts.push(part); }; + for await (const plaintext of jsonChunks(backup)) { + total += plaintext.length; hash = await chain(hash, plaintext); + const output = encrypted ? new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivFor(nonce, count), additionalData: aadFor(headerLine, 'chunk', count) }, key, plaintext)) : plaintext; + append(JSON.stringify({ kind: 'chunk', index: count, data: HTTP.bytesToB64(output) })); count++; + options.onProgress?.({ phase: 'export', bytes: total, total: null }); + await pause(); + } + const footer = { chunks: count, bytes: total, hash: hex(hash) }; + if (encrypted) { + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivFor(nonce, count), additionalData: aadFor(headerLine, 'end', count) }, key, encoder.encode(JSON.stringify(footer))); + append(JSON.stringify({ kind: 'end', data: HTTP.bytesToB64(new Uint8Array(ciphertext)) })); + } else append(JSON.stringify({ kind: 'end', ...footer })); + return new Blob(parts, { type: 'application/x-ndjson' }); + } + + async function createBlob(backup, password = '', options = {}) { + Lab.validateBackup(backup, { clone: false }); + return encodeBlob(backup, password, options); + } + // A corrupt saved record must remain recoverable for manual repair. This + // explicit path preserves its raw values, without promising a valid restore. + // It shares all serialization bounds and never overwrites local storage. + async function recoveryBlob(savedRecord, options = {}) { + return encodeBlob(savedRecord, '', options); + } + + async function* fileBytes(file) { + // Slicing imposes a bound even when a browser/Blob stream chooses huge reads. + for (let position = 0; position < file.size; position += CHUNK_BYTES) yield new Uint8Array(await file.slice(position, position + CHUNK_BYTES).arrayBuffer()); + } + async function* lines(file, options) { + const decoder = new TextDecoder('utf-8', { fatal: true }); + let pending = '', progress = 0; + for await (const bytes of fileBytes(file)) { + progress += bytes.length; pending += decoder.decode(bytes, { stream: true }); + let end; + while ((end = pending.indexOf('\n')) !== -1) { + const line = pending.slice(0, end); pending = pending.slice(end + 1); + if (line.length > 4 * Math.ceil((CHUNK_BYTES + 16) / 3) + 256) fail('Backup frame is too large.'); + yield line; + } + if (pending.length > 4 * Math.ceil((CHUNK_BYTES + 16) / 3) + 256) fail('Backup frame is too large.'); + options.onProgress?.({ phase: 'import', bytes: progress, total: file.size }); await pause(); + } + pending += decoder.decode(); + if (pending) fail('Backup is incomplete: missing final line ending.'); + } + async function readFramed(file, password, options) { + const reader = new JsonReader(), decoder = new TextDecoder('utf-8', { fatal: true }); + let header, headerLine, key, nonce, count = 0, total = 0, hash = new Uint8Array(32), ended = false, short = false; + for await (const line of lines(file, options)) { + if (ended) fail('Unexpected data after backup footer.'); + const frameReader = new JsonReader(); frameReader.feed(line); + const frame = frameReader.finish(); + if (!header) { + if (!frame || frame.format !== 'interceptor-backup-stream' || frame.version !== 2 || frame.encoding !== 'base64' || frame.chunkBytes !== CHUNK_BYTES || typeof frame.encrypted !== 'boolean') fail('Unsupported full backup header.'); + const allowed = frame.encrypted ? ['format', 'version', 'encoding', 'chunkBytes', 'encrypted', 'salt', 'nonce', 'iterations'] : ['format', 'version', 'encoding', 'chunkBytes', 'encrypted']; + if (Object.keys(frame).length !== allowed.length || Object.keys(frame).some((key) => !allowed.includes(key))) fail('Invalid full backup header.'); + header = frame; headerLine = line; + if (header.encrypted) { + if (header.iterations !== 200_000) fail('Unsupported backup key derivation.'); + const salt = decode64(header.salt, 16); nonce = decode64(header.nonce, 8); + if (salt.length !== 16 || nonce.length !== 8) fail('Invalid encrypted backup metadata.'); + key = await keyFor(password, salt); + } + continue; + } + if (!frame || typeof frame !== 'object' || Array.isArray(frame)) fail('Invalid backup frame.'); + if (frame.kind === 'chunk') { + if (short || frame.index !== count || Object.keys(frame).length !== 3 || Object.keys(frame).some((key) => !['kind', 'index', 'data'].includes(key))) fail('Missing, reordered or invalid backup chunk.'); + let bytes = decode64(frame.data, CHUNK_BYTES + (header.encrypted ? 16 : 0)); + if (header.encrypted) { + try { bytes = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivFor(nonce, count), additionalData: aadFor(headerLine, 'chunk', count) }, key, bytes)); } + catch { fail('Unable to unlock backup: wrong password or damaged file.'); } + } + if (!bytes.length || bytes.length > CHUNK_BYTES) fail('Invalid backup chunk size.'); + short = bytes.length < CHUNK_BYTES; total += bytes.length; + if (total > MAX_JSON_BYTES) budgetError(); + hash = await chain(hash, bytes); reader.feed(decoder.decode(bytes, { stream: true })); count++; + } else if (frame.kind === 'end') { + let footer = frame; + if (header.encrypted) { + if (Object.keys(frame).length !== 2) fail('Invalid encrypted backup footer.'); + try { + const bytes = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivFor(nonce, count), additionalData: aadFor(headerLine, 'end', count) }, key, decode64(frame.data, 1024)); + footer = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + } catch { fail('Unable to unlock backup: wrong password or damaged file.'); } + } + const expectedKeys = header.encrypted ? ['chunks', 'bytes', 'hash'] : ['kind', 'chunks', 'bytes', 'hash']; + if (Object.keys(footer).length !== expectedKeys.length || Object.keys(footer).some((key) => !expectedKeys.includes(key)) || footer.chunks !== count || footer.bytes !== total || footer.hash !== hex(hash) || !count) fail('Backup is incomplete or damaged.'); + reader.feed(decoder.decode()); ended = true; + } else fail('Unknown backup frame.'); + } + if (!ended) fail('Backup is incomplete: final verification footer is missing.'); + return Lab.validateBackup(reader.finish(), { clone: false }); + } + async function readLegacy(file, password, options) { + // Original encrypted envelopes have one large Base64 scalar. Allow that + // scalar only inside the legacy 200 MB compatibility budget. + const reader = new JsonReader({ legacyEncryptedBytes: file.size <= MAX_LEGACY_ENCRYPTED_BYTES ? MAX_LEGACY_ENCRYPTED_BYTES : 0 }), decoder = new TextDecoder('utf-8', { fatal: true }); let total = 0; + for await (const bytes of fileBytes(file)) { + total += bytes.length; if (total > MAX_JSON_BYTES) budgetError(); + reader.feed(decoder.decode(bytes, { stream: true })); + options.onProgress?.({ phase: 'import', bytes: total, total: file.size }); await pause(); + } + reader.feed(decoder.decode()); let input = reader.finish(); + if (input?.format === 'interceptor-encrypted-backup') { + if (file.size > MAX_LEGACY_ENCRYPTED_BYTES) fail('Legacy encrypted backups support up to 200 MB. Unlock and re-export using a version that created this file; new chunked backups support 1 GB of workspace JSON.'); + input = await Lab.decryptBackup(input, password); + } + return Lab.validateBackup(input, { clone: false }); + } + async function readFile(file, password = '', options = {}) { + if (!file || typeof file.slice !== 'function' || !Number.isSafeInteger(file.size) || file.size < 1) fail('Choose a non-empty workspace backup file.'); + if (file.size > MAX_FILE_BYTES) fail('Full backup files support up to 1.4 GB, representing at most 1 GB of workspace JSON. No workspace data was changed.'); + const prefix = await file.slice(0, 8192).text(); + let first; + try { first = JSON.parse(prefix.split('\n')[0]); } catch { /* Ordinary legacy JSON may span lines. */ } + return first?.format === 'interceptor-backup-stream' ? readFramed(file, password, options) : readLegacy(file, password, options); + } + const api = { createBlob, recoveryBlob, readFile, limits: Object.freeze({ chunkBytes: CHUNK_BYTES, jsonBytes: MAX_JSON_BYTES, fileBytes: MAX_FILE_BYTES, nodes: MAX_NODES, depth: MAX_DEPTH, stringChars: MAX_STRING_CHARS }) }; + global.Backup = api; + if (typeof module !== 'undefined' && module.exports) module.exports = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/consent.css b/consent.css new file mode 100644 index 0000000..5d6e0f4 --- /dev/null +++ b/consent.css @@ -0,0 +1,28 @@ +.disclosure-dialog { + width: min(680px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); + box-sizing: border-box; overflow: auto; padding: 28px; border: 1px solid #394352; + border-radius: 16px; background: #171d27; color: #edf1f8; + box-shadow: 0 24px 100px #0009; font: 14px/1.65 system-ui, sans-serif; +} +.disclosure-dialog::backdrop { background: #080c14b8; backdrop-filter: blur(4px); } +.disclosure-dialog h1 { font-size: 24px; line-height: 1.25; margin: 0 0 18px; } +.disclosure-dialog h2 { font-size: 18px; margin: 22px 0 8px; } +.disclosure-dialog p { margin: 12px 0; } +.disclosure-dialog a { color: #ff9d58; display: inline-block; margin-right: 16px; } +.disclosure-dialog li { margin: 10px 0; } +.disclosure-dialog .hint { color: #aeb9c9; font-size: 12px; } +.disclosure-dialog button { min-height: 40px; padding: 9px 14px; cursor: pointer; font: inherit; } +.disclosure-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 22px; } +.disclosure-error { color: #ff9c9c; } +.disclosure-dialog :focus-visible { outline: 2px solid #ff9d58; outline-offset: 3px; } +@media (prefers-color-scheme: light) { + .disclosure-dialog { background: #fff; color: #1a2330; border-color: #c7d0dd; } + .disclosure-dialog a { color: #a34400; } + .disclosure-dialog .hint { color: #586779; } + .disclosure-error { color: #b52d34; } +} +@media (max-width: 480px) { + .disclosure-dialog { padding: 18px; font-size: 13px; width: calc(100vw - 20px); max-height: calc(100dvh - 20px); } + .disclosure-dialog h1 { font-size: 20px; } + .disclosure-actions { flex-direction: column; } +} diff --git a/consent.js b/consent.js new file mode 100644 index 0000000..003740d --- /dev/null +++ b/consent.js @@ -0,0 +1,86 @@ +'use strict'; +/* Shared local-data disclosure. No testing data is read before UI acceptance. */ +(function (global) { + const REVISION = 1; + const STORAGE_KEY = 'localDataDisclosure'; + let pending; + async function accepted() { + try { + const data = await chrome.storage.local.get(STORAGE_KEY); + return data[STORAGE_KEY]?.revision === REVISION && typeof data[STORAGE_KEY]?.acceptedAt === 'string' && Number.isFinite(Date.parse(data[STORAGE_KEY].acceptedAt)); + } catch { return false; } + } + async function acknowledge() { + await chrome.storage.local.set({ [STORAGE_KEY]: { revision: REVISION, acceptedAt: new Date().toISOString() } }); + } + function element(tag, text, className) { + const node = document.createElement(tag); + if (text) node.textContent = text; + if (className) node.className = className; + return node; + } + function policyLink() { + const link = element('a', 'Privacy policy'); + link.href = 'https://github.com/user-github-me/interceptor/blob/main/PRIVACY.md'; + link.target = '_blank'; link.rel = 'noopener noreferrer'; + return link; + } + async function requireDisclosure(options = {}) { + if (await accepted()) return true; + if (pending) return pending; + pending = new Promise((resolve) => { + const dialog = element('dialog', '', 'disclosure-dialog'); + dialog.setAttribute('aria-labelledby', 'disclosureTitle'); + const title = element('h1', 'Your testing work stays in this browser profile'); title.id = 'disclosureTitle'; + dialog.append(title, + element('p', 'Interceptor automatically saves selected HTTP/WebSocket traffic, requests, responses, drafts, variables, and authentication fields on this PC. Saved data can include cookies, tokens, passwords, and personal information.'), + element('p', 'The developer does not receive your testing data. Requests you start, forward, or automatically rewrite go to the testing targets you choose. Passive review and imports send no requests.'), + element('p', 'The local database is not separately encrypted by Interceptor. Workspace offers full backup, optional backup encryption, restore, and undo. Clear history and delete requests or environments in their tools. Downloaded files and the restore undo copy are separate copies.'), + policyLink()); + const error = element('p', '', 'disclosure-error'); error.setAttribute('role', 'alert'); + const actions = element('div', '', 'disclosure-actions'); + const continueButton = element('button', 'Continue and save locally', 'primary'); + const closeButton = element('button', options.closeLabel || 'Close workbench'); + actions.append(continueButton, closeButton); + if (options.recover) { + const recover = element('button', 'Download existing saved work'); + recover.addEventListener('click', async () => { + recover.disabled = true; error.textContent = ''; + try { await options.recover(); } + catch (e) { error.textContent = `Recovery failed: ${e.message}`; } + finally { recover.disabled = false; } + }); + dialog.append(element('p', 'Already have a saved workspace? Download it without starting new capture or replacing it.', 'hint'), recover); + } + dialog.append(error, actions); + function finish(value) { dialog.close(); dialog.remove(); pending = null; resolve(value); } + continueButton.addEventListener('click', async () => { + continueButton.disabled = true; error.textContent = ''; + try { await acknowledge(); finish(true); } + catch (e) { error.textContent = `Could not save your choice: ${e.message}. Testing has not started.`; continueButton.disabled = false; } + }); + closeButton.addEventListener('click', () => { finish(false); global.close(); }); + dialog.addEventListener('cancel', (event) => { event.preventDefault(); closeButton.click(); }); + document.body.append(dialog); dialog.showModal(); continueButton.focus(); + }); + return pending; + } + function showHelp() { + const dialog = element('dialog', '', 'disclosure-dialog help-dialog'); + dialog.setAttribute('aria-labelledby', 'helpTitle'); + const title = element('h1', 'Start with your app'); title.id = 'helpTitle'; + const list = element('ol'); + for (const text of ["Select your app’s tab and Attach. Chrome’s debugging banner is expected: it grants the access used to capture and edit traffic.", 'Turn Intercept ON, use your app, then edit and Forward or Drop a paused request. Detach to release paused traffic and stop recording.', 'Send a request to Repeater or build one in API Builder. Sending starts traffic to your selected URL; HTTP is unencrypted. Runner repeats up to 50 requests and can be stopped.', 'Auto mode is configured in the toolbar popup. Check the tab scope and URL include/exclude rules before enabling it. A blank include list permits all HTTP(S) URLs in that scope.', 'Security Review and Site Map inspect recorded traffic. Observations need context and manual verification; imports and passive tools do not send requests.']) list.append(element('li', text)); + dialog.append(title, list, element('h2', 'Local data and permissions'), + element('p', 'Your workspace saves automatically in this browser profile, including credentials and frame previews. The database is not separately encrypted. Workspace downloads full backups with optional password encryption and offers validated restore with undo. Backup files remain where you downloaded them.'), + element('p', 'Debugger access captures and edits selected traffic. Host access supports your chosen test sites and API endpoints. Local storage retains work and settings. Temporary header rules reproduce custom request headers; clipboard access powers Copy buttons.'), policyLink()); + const docs = element('a', 'Documentation and support'); docs.href = 'https://github.com/user-github-me/interceptor'; docs.target = '_blank'; docs.rel = 'noopener noreferrer'; dialog.append(docs); + const actions = element('div', '', 'disclosure-actions'); const close = element('button', 'Close help', 'primary'); + close.addEventListener('click', () => { dialog.close(); dialog.remove(); }); + dialog.addEventListener('cancel', (event) => { event.preventDefault(); close.click(); }); + actions.append(close); dialog.append(actions); document.body.append(dialog); dialog.showModal(); close.focus(); + } + const api = { REVISION, STORAGE_KEY, accepted, ready: accepted, acknowledge, require: requireDisclosure, showHelp }; + global.Disclosure = api; + if (typeof module !== 'undefined' && module.exports) module.exports = api; +})(globalThis); diff --git a/dashboard.html b/dashboard.html index f17f74f..878f928 100644 --- a/dashboard.html +++ b/dashboard.html @@ -9,6 +9,7 @@ +

@@ -22,6 +23,7 @@ Not attached +