From 9290733bb8a94efdcd547e9fce47dbd847df378b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:05:24 +0900 Subject: [PATCH 1/6] docs --- AGENTS.md | 6 +- docs/DOC-MAINTENANCE.md | 3 +- docs/README.md | 1 + docs/develop.md | 5 +- docs/specs/csp-rule-management.md | 772 ++++++++++++++++++++++++++++++ 5 files changed, 784 insertions(+), 3 deletions(-) create mode 100644 docs/specs/csp-rule-management.md diff --git a/AGENTS.md b/AGENTS.md index 4d177ad75..9581cf20d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ This file provides guidance to AI coding agents (Claude Code, etc.) when working > **Before writing any code, read [`docs/develop.md`](docs/develop.md)** — the development spec (commands, > project structure, coding style, UI & theme rules, testing mechanics, i18n, and the commit/PR workflow). This > file keeps only the non-negotiable engineering principles and the architecture map; the concrete "how" lives -> in `DEVELOP.md`, and deep internals in [`docs/architecture.md`](docs/architecture.md). +> in [`docs/develop.md`](docs/develop.md), and deep internals in [`docs/architecture.md`](docs/architecture.md). > **To manually verify a feature actually works, read [`docs/verification.md`](docs/verification.md)** — drive > the real built extension end-to-end with one-shot throwaway scratch scripts (not the committed test suite). @@ -24,6 +24,10 @@ This file provides guidance to AI coding agents (Claude Code, etc.) when working > **Doc map:** [`docs/README.md`](docs/README.md) indexes every contributor doc (development, architecture, > translation, contributing, localized READMEs). +> +> **Feature specifications:** [`docs/specs/csp-rule-management.md`](docs/specs/csp-rule-management.md) +> owns the planned CSP rule-management product, UX, technical, and acceptance contract. Read it before +> implementing or reviewing that feature; it is target behavior, not a claim that the feature is shipped. ## Project Overview diff --git a/docs/DOC-MAINTENANCE.md b/docs/DOC-MAINTENANCE.md index e1fd76b79..dc7b87215 100644 --- a/docs/DOC-MAINTENANCE.md +++ b/docs/DOC-MAINTENANCE.md @@ -30,6 +30,7 @@ Aspirational / feature-branch content belongs in that branch's docs, or is clear | [`design.md`](./design.md) | The design system: theme mechanism, shadcn component selection, new-page recipe; tokens split to [`references/design-tokens.md`](./references/design-tokens.md), component palette to [`references/design-components.md`](./references/design-components.md), layout/motion/state/a11y patterns to [`references/design-patterns.md`](./references/design-patterns.md). | | [`verification.md`](./verification.md) | Lightweight end-to-end functional verification — throwaway scratch scripts driving the real built extension; report template split to [`references/verification-report-template.md`](./references/verification-report-template.md), debugging FAQ to [`references/verification-debugging.md`](./references/verification-debugging.md). | | [`architecture.md`](./architecture.md) | Deep internals: process model, message passing; subsystem deep-dives split to [`references/architecture-services.md`](./references/architecture-services.md), [`references/architecture-data.md`](./references/architecture-data.md), [`references/architecture-gm-api.md`](./references/architecture-gm-api.md), [`references/architecture-execution.md`](./references/architecture-execution.md), [`references/architecture-build.md`](./references/architecture-build.md). | +| [`specs/csp-rule-management.md`](./specs/csp-rule-management.md) | Planned CSP rule-management product, UX, technical, acceptance, and decision contract; implementation mechanics stay in `develop.md`. | | [`translation.md`](./translation.md) | Translation / localization single source of truth. | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | This guide: doc-set organization rules + fact-check / anti-drift discipline. | | [`README.md`](./README.md) | The index that points to all of the above. | @@ -98,7 +99,7 @@ git ls-files eslint-rules/; git grep -l "require-last-error-check" -- eslint.con Link integrity — confirm every relative markdown link in the core docs resolves: ```bash -for doc in AGENTS.md docs/README.md docs/DOC-MAINTENANCE.md docs/develop.md docs/references/develop-testing.md docs/design.md docs/references/design-tokens.md docs/references/design-components.md docs/references/design-patterns.md docs/architecture.md docs/references/architecture-services.md docs/references/architecture-data.md docs/references/architecture-gm-api.md docs/references/architecture-execution.md docs/references/architecture-build.md docs/verification.md docs/references/verification-debugging.md docs/references/verification-report-template.md docs/translation.md; do +for doc in AGENTS.md docs/README.md docs/DOC-MAINTENANCE.md docs/develop.md docs/references/develop-testing.md docs/design.md docs/references/design-tokens.md docs/references/design-components.md docs/references/design-patterns.md docs/architecture.md docs/references/architecture-services.md docs/references/architecture-data.md docs/references/architecture-gm-api.md docs/references/architecture-execution.md docs/references/architecture-build.md docs/verification.md docs/references/verification-debugging.md docs/references/verification-report-template.md docs/translation.md docs/specs/csp-rule-management.md; do # the sed pipeline drops fenced code blocks and inline code spans first, so illustrative sample # links inside ```md snippets or `single-backtick` text (e.g. references/verification-report-template.md's # screenshot/resource examples, verification.md's "Evidence location" spans) aren't false-flagged as broken diff --git a/docs/README.md b/docs/README.md index f1a280e41..866128dd7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ | [`design.md`](./design.md) | 设计系统参考:主题机制、shadcn 组件选型、新建页面配方总览;令牌完整值拆到 [`references/design-tokens.md`](./references/design-tokens.md),组件清单拆到 [`references/design-components.md`](./references/design-components.md),布局/响应式/动效/状态/无障碍范式拆到 [`references/design-patterns.md`](./references/design-patterns.md)。**做页面/对话框/区块前先读。** | | [`verification.md`](./verification.md) | 功能验证指南:用一次性 scratch 脚本驱动真实扩展做端到端验证(不跑全量 E2E、不加永久用例);报告模板拆到 [`references/verification-report-template.md`](./references/verification-report-template.md),调试 FAQ 拆到 [`references/verification-debugging.md`](./references/verification-debugging.md)。**验证改动是否真正跑通时读。** | | [`architecture.md`](./architecture.md) | 内部原理总览:多进程模型、消息传递;各子系统深入拆到 [`references/architecture-services.md`](./references/architecture-services.md)(服务层)、[`references/architecture-data.md`](./references/architecture-data.md)(数据层)、[`references/architecture-gm-api.md`](./references/architecture-gm-api.md)(GM API)、[`references/architecture-execution.md`](./references/architecture-execution.md)(脚本执行)、[`references/architecture-build.md`](./references/architecture-build.md)(构建管线)。 | +| [`specs/csp-rule-management.md`](./specs/csp-rule-management.md) | CSP 规则管理目标规格:讨论取舍、产品边界、目标 UI/UX 交互、域名语义、DNR 技术合同、验收与实施顺序。**实现前先读。** | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、一键校验脚本。**改/审文档前先读。** | ## 翻译 / Translation diff --git a/docs/develop.md b/docs/develop.md index bff7539ef..ca77f42e9 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -4,7 +4,10 @@ > principles (SOLID / high cohesion & low coupling, TDD/BDD-first, root-cause fixes, scope discipline) and the > architecture quick-map — those are **not** repeated here. This file is the concrete development spec: the > commands, structure, coding style, UI/theme rules, testing mechanics, i18n, and commit/PR workflow you follow -> while implementing. For deep internals see [`docs/architecture.md`](./architecture.md). +> while implementing. For deep internals see [`docs/architecture.md`](./architecture.md). Feature-specific +> target behavior stays in its owning spec; read that spec before implementation and do not infer shipped +> behavior from a target-only document. CSP rule management is defined in +> [`specs/csp-rule-management.md`](./specs/csp-rule-management.md). ## Commands diff --git a/docs/specs/csp-rule-management.md b/docs/specs/csp-rule-management.md new file mode 100644 index 000000000..5f537975b --- /dev/null +++ b/docs/specs/csp-rule-management.md @@ -0,0 +1,772 @@ +# CSP 规则管理功能规格 + +> 状态:目标规格,尚未在当前分支实现。最后核对日期:2026-07-17。 +> +> 实现本规格前仍须遵守 [开发规范](../develop.md)、[设计系统](../design.md)、 +> [测试规范](../references/develop-testing.md)、[翻译规范](../translation.md)和 +> [真实扩展验证指南](../verification.md)。本文件只拥有 CSP 规则管理的产品、UX、技术合同与取舍。 + +## 0. 读者与规范性约定 + +本文件同时服务于实现者、测试作者、UI 审查者和发布审核者。除第 2 节“讨论结论”和第 14 节“决策记录” +只用于说明来源与理由外,其他章节都是第一版的目标合同。 + +- “必须”表示验收失败即不能合并;“应”表示实现应遵循,除非在代码评审中记录理由;“可以”表示非必需选项。 +- “规则”表示用户看到并编辑的**逻辑规则**;“DNR 规则”表示浏览器中的 + `chrome.declarativeNetRequest.Rule`。一个逻辑规则不等于一个 DNR 规则。 +- 本文件新增的路径、类型和 ID 都是计划内容,不代表当前分支已经存在。实现开始前必须重新核对路径和现有 + API;若代码形状已变化,先更新本文件或在实现计划中记录差异,再写代码。 +- 本文件中的英文 UI 文案是翻译基准值,不是允许直接写入 JSX 的硬编码文本。所有用户可见文本必须按 + [翻译规范](../translation.md)加入对应 namespace,并在 8 个现有 locale 中提供值。 + +### 0.1 完成定义 + +CSP 规则管理只有同时满足以下条件才算完成: + +1. 第 12.1 节的领域、持久化、编译、同步和并发测试通过。 +2. 第 12.2 节的 UI 测试覆盖每个列出的状态和操作,且 `pnpm run lint`、`pnpm run typecheck` 与相关 + `pnpm test -- --run ...` 通过。 +3. 第 12.3 节的 Chrome `>= 120`、Edge `>= 120` 和 Firefox `>= 136` 手动验证均完成;浏览器失败或 + 未验证的项目必须在报告中标为未验证,不能用“应用成功”替代。 +4. 所有代码路径、测试路径和本规格中的目标路径相互一致;实现后必须更新本文件的状态标记和 + `docs/verification.md` 所要求的验证记录。 + +## 1. 目标与成功定义 + +为遇到网页 CSP 阻止用户脚本开发或运行的用户,提供一个**由用户主动配置、按网站域名生效、可随时暂停** +的 CSP 响应头移除工具。 + +第一版成功必须同时满足: + +1. 一般用户只需粘贴网址或输入域名,不需学习 DNR、正则表达式或 whistle pattern。 +2. 规则以域名为单位,覆盖该域名的全部路径,SPA 使用 `history.pushState` 改变路径不会造成“看似应生效、 + 实际未重新触发”的路径规则陷阱。 +3. 默认不会影响任何网站;没有启用规则时,ScriptCat 不注册 CSP 修改规则。 +4. 网站开发者或用户脚本作者不能通过 metadata 代用户开启此能力;只有扩展用户可在工具页配置。 +5. 用户能看到生效范围、风险、应用失败状态,并能用总开关统一暂停/恢复全部 CSP 规则。 +6. 数据模型允许一条逻辑规则包含多个域名,也允许一个域名出现在多条逻辑规则中。 +7. 第一版只移除 CSP 相关响应头;今后只有出现实际需求时才增加新的 action,不预先做通用代理或改头工具。 + +主要回归用例来自 [issue #866](https://github.com/scriptscat/scriptcat/issues/866):开发模式以网络方式加载的 +脚本会受页面 CSP 阻止,单靠 `inject-into content` 无法解决网络层 CSP。 + +## 2. 讨论结论与优先级 + +本规格已核对 [PR #1264](https://github.com/scriptscat/scriptcat/pull/1264) 和 +[PR #1348](https://github.com/scriptscat/scriptcat/pull/1348) 的 PR 描述、全部顶层评论、改动文件、 +reviews 与 review threads;两份 PR 均没有 inline review thread 或已提交 review。发生冲突时, +以 cyfung1031 的较新意见为先。 + +| 讨论重点 | 决定 | +| --- | --- | +| [cyfung1031:不要像 Tampermonkey 只放一个“全局禁用 CSP”开关,用户要知道自己在做什么](https://github.com/scriptscat/scriptcat/pull/1264#issuecomment-3912226560) | 主界面采用域名规则清单;总开关只负责暂停/恢复现有规则,绝不代表“所有网站” | +| [cyfung1031:只在用户需要时加入域名;规则与是否存在适用脚本无关](https://github.com/scriptscat/scriptcat/pull/1264#issuecomment-3912230053) | 规则由用户手动创建;目标域名一旦匹配,即使该页没有脚本也会移除 CSP | +| [CodFrm 建议、cyfung1031 确认可支持 `*` 全部网站](https://github.com/scriptscat/scriptcat/pull/1264#issuecomment-4103877087) | 保留“所有网站”能力,但放在新增/编辑表单的“高级范围”中;启用时必须二次确认,不以裸 `*` 作为普通输入教学 | +| [boommanpro:可演进为修改响应头或插件转发](https://github.com/scriptscat/scriptcat/pull/1264#issuecomment-4229250344) | 第一版不做。action 使用可判别类型保留演进边界;新增 action 必须另写需求、冲突语义和审核说明 | +| [CodFrm:此功能低频,适合放在设置或 Tools](https://github.com/scriptscat/scriptcat/pull/1348#issuecomment-4248998313) | 放入当前 Options 的“工具”页,沿用 `SettingsLayout`,不新增一级导航 | +| [cyfung1031:whistle 规则语法资料与 domain/subdomain 语义不清](https://github.com/scriptscat/scriptcat/pull/1348#issuecomment-4259584654) | 不沿用 whistle pattern,不提供正则、路径、协议或 `* / ** / ***` 语法 | +| [cyfung1031:多数网站为 SPA,路径切换会让 URL 匹配产生误解](https://github.com/scriptscat/scriptcat/pull/1348#issuecomment-4622331109) | 第一版只按整个域名匹配;所有路径自动包含 | +| [cyfung1031:一条规则应可含多个域名,一个域名也可对应多条规则](https://github.com/scriptscat/scriptcat/pull/1348#issuecomment-4622331109) | 作为持久化模型和 CRUD 合同;编译 DNR 时可按 action 合并、去重 | +| [cyfung1031:功能过多可能影响产品定位与商店审核](https://github.com/scriptscat/scriptcat/pull/1348#issuecomment-4622365783) | 不增加远程云控、通用代理、脚本声明或新权限;商店说明明确“用户本地、按网站、自行启用” | +| [CodFrm:此功能不需要新增权限](https://github.com/scriptscat/scriptcat/pull/1348#issuecomment-4623028317) | 保持当前 manifest 已有的 `declarativeNetRequest` 与 ``;不添加 PR #1348 提议的 `declarativeNetRequestWithHostAccess` | + +## 3. 产品边界 + +### 3.1 第一版范围 + +- 工具页中的 CSP 规则设置区。 +- CSP 规则的创建、编辑、删除、启用、禁用。 +- 全部规则的总暂停/恢复。 +- 一条 `domains` 规则包含 1–100 个规范化域名;最多保存 100 条逻辑规则,所有 `domains` 规则合计最多 + 1,000 个不同规范化域名。`allSites` 规则不占用域名数量,但仍占用逻辑规则数量。 + 这些是第一版明确上限,出现真实超限需求后再调整。 +- 普通目标“域名及其所有子域名”,以及需要确认的高级目标“所有网站”。 +- 域名/网址粘贴、规范化、去重和字段级错误提示。 +- 将所有启用的“移除 CSP”逻辑规则编译为至多 1 条 DNR dynamic rule。 +- Chrome/Edge 与仓库当前最低 Firefox 136 的实现和验证。 +- 操作成功后明确提示:规则只影响后续网络响应,已打开页面需要重新加载。 + +上限在 service worker 中重新验证,而不是只在表单中验证。创建或编辑操作必须先规范化并去重,再以整个 +候选 state 计算上限;超过任一上限时不得写入部分数据,也不得调用 DNR。编辑已有规则时,已有规则的域名先 +从全局集合中移除,再加入候选规则的域名,避免把自己重复计算。 + +规则集合不排序、不拖动:新规则追加到 `rules` 数组,编辑和启停保持数组位置,删除移除该元素;列表按数组 +顺序显示。域名 badge 按用户输入首次出现的顺序显示,compiler 为了确定性测试再按 ASCII 升序输出 DNR +`requestDomains`。 + +### 3.2 明确不做 + +- 不允许用户脚本用 metadata 或 API 新建、启用或扩宽规则。 +- 不自动从脚本的 `@match`、`@include` 或当前运行页面生成规则。 +- 不做 URL path、query、hash、协议、DNR `urlFilter`、正则或 whistle pattern 编辑器。 +- 不做“匹配测试器”;域名规则无需另一个与浏览器实现可能漂移的匹配引擎。 +- 不移除 `X-Frame-Options`,它不是 CSP,且会额外改变页面能否被嵌入。 +- 不修改 CSP 为用户输入值,不做任意 request/response header 编辑。 +- 不做优先级、拖动排序或冲突编辑器;第一版只有幂等的“移除同一组头”。 +- 不做远程规则、云控、订阅、企业策略或服务器下发。 +- 不自动刷新、关闭或重载用户标签页。 +- 不承诺绕过 HTML ``,也不承诺修改由页面 Service Worker / + CacheStorage 直接生成、未经过网络栈的响应。 +- 第一版不进入设置备份或云同步;这是高影响安全配置,日后若要导出,必须由用户显式选择并单独评审。 +- 不迁移或猜测任何旧的 CSP 规则存储格式。第一版只接受第 7 节声明的 `schemaVersion: 1`;未来格式必须 + 通过单独的迁移决定和回归测试加入。 + +## 4. 用户心智模型与文案 + +功能名称: + +- 英文:`CSP rules` +- 简体中文:`CSP 规则` + +工具卡说明应直接表达: + +> Remove CSP response headers only from websites you choose. This weakens those websites' built-in protection. + +简体中文语义: + +> 只在你选择的网站移除 CSP 响应头。这会削弱这些网站原有的安全保护。 + +必须持续可见的事实: + +- “域名规则覆盖所有路径,并包含其子域名。” +- “规则与脚本是否运行无关。” +- “保存、启用、禁用或暂停后,请重新加载受影响页面。” +- “某些网站会检查 CSP 是否存在;移除后可能拒绝工作,金融网站尤其应谨慎。” +- “移除 CSP 也会移除该响应头内的 Trusted Types 指令;不另设 Trusted Types 开关。” + +不要使用“修复网站”“安全运行”“保证注入成功”等保证性文案。CSP 可能只是失败原因之一。 + +英文文案表中的句子是默认 locale 的基准文案;实现必须使用 `tools` namespace 的翻译 key,不得在 +`CspRulesSection.tsx`、`CspRuleSheet.tsx` 或 toast 调用中直接写这些句子。错误详情分为可翻译的错误摘要和 +可折叠的原始浏览器错误;原始错误只用于诊断,不作为翻译 key。 + +规则名称的默认值也必须由纯函数生成,避免不同页面生成不同结果: + +- `domains` 目标:使用第一个规范化域名;若还有 `N` 个域名,追加 ` + N`,例如 3 个域名生成 + `github.com + 2`。 +- `allSites` 目标:使用 `All websites` 的翻译值。 +- 用户输入的名称 trim 后为空时才使用默认值;非空名称保留用户输入的大小写,最多 80 个 Unicode code + points。默认名称在保存时按当前 locale 生成并持久化;之后渲染或切换 locale 不重新生成。名称不参与匹配。 + +## 5. 信息架构与交互 + +### 5.1 入口 + +在现有 `/tools` 页面新增一个 scroll-spy 分类和 `SettingCard`: + +- 分类位置:数据迁移之后、开发工具之前。 +- 分类和卡片 ID 固定为 `csp-rules`;在 `src/pages/options/routes/Tools/categories.ts` 注册分类,在 + `src/pages/options/routes/Tools/index.tsx` 按上述顺序渲染卡片。 +- 分类图标:`ShieldOff`(lucide-react)。 +- 不新增侧栏一级路由,不打开独立 1400px 横向表格,不使用已移除的 Arco `Drawer/Table/Form`。 +- 复用 `SettingsLayout`、`SettingCard`、`SettingRow`、shadcn primitives、设计 token 和 `notify`。 + +### 5.2 卡片首屏 + +从上到下: + +1. 标题“CSP rules”和风险帮助按钮。 +2. 总开关“Run CSP rules”: + - 默认为开;空规则时无网络效果。 + - 关闭只暂停,不改变每条规则自己的 enabled 状态。 + - 开启后恢复原先逐条状态。 +3. 状态摘要: + - 正常且只有域名目标:`3 active rules · 8 websites`;active rules 是 enabled 逻辑规则数,websites 是 + enabled 域名目标去重后的数量。 + - 正常且存在启用的 `allSites`:显示 active rules 数和 `All websites`,不得把所有网站伪装成一个域名。 + - 暂停:`Paused · 3 rules kept`;rules kept 是 enabled 逻辑规则数,包含 `allSites`。 + - 应用失败:红色错误状态、原始错误摘要和“Retry”按钮。 +4. 主按钮“Add rule”。 +5. 规则清单或空状态。 + +第一次加载保持卡片结构,用 `Skeleton` 显示摘要与 2 行占位;不可用空白卡片或只在控制台记录错误。 + +UI 必须按以下状态渲染,而不是通过“有没有数据”猜测状态: + +| 状态 | 进入条件 | 必须显示 | 允许的操作 | +| --- | --- | --- | --- | +| loading | 首次请求尚未返回 | 卡片、摘要 Skeleton、2 行规则 Skeleton | 不提交操作 | +| applied | `apply.state === "applied"` | 规则和 active websites 摘要 | 全部 CRUD、启停、暂停/恢复 | +| paused | `masterEnabled === false` 且没有应用错误 | `Paused` 和保留的启用规则数量 | 可以编辑规则;不产生网络修改 | +| error | state 已保存但 DNR reconcile 失败 | 可翻译摘要、原始错误详情、`Retry`;注明浏览器可能仍使用上一 revision | 允许 Retry 和新的 CRUD;新请求完成前只禁用发起请求的控件 | +| load-error | 无法读取或 schema 不受支持 | 可操作错误和恢复说明 | 不显示可编辑规则,不伪造空状态 | + +应用失败时仍显示已保存的逻辑 state,但不能显示“已应用”或“已暂停”。`Retry` 只重试 service worker +当前保存的 state,不从 UI 草稿重建 state;重新打开工具页必须再次从 service worker 读取。 + +### 5.3 规则清单 + +每条规则展示: + +- 用户名称;未输入时由首个域名生成,例如 `github.com + 2`。 +- 目标摘要:最多展示前 3 个域名 badge,其余显示 `+N`;所有网站规则显示 danger/warning 语义的 + `All websites` badge。 +- action 固定显示 `Remove CSP`。 +- enabled switch;切换期间只禁用当前 switch 并显示内联 loading。 +- 更多菜单:Edit、Delete。删除使用行内 `popconfirm`。 + +桌面为同一卡片内的紧凑列表行;移动端改为单列小卡,不使用横向滚动表格。移动端 switch、菜单和主要按钮的 +可点击区域不小于约 44px。首批显示 20 条,之后每次“Show more”再显示 20 条,避免 100 条上限把整个工具页 +一次性撑开。 + +“Show more”只是 UI 的本地分页:一次 `getState` 返回完整逻辑规则数组,首次 `visibleCount = 20`,每次 +点击增加 20,达到数组长度后隐藏按钮。删除后保留当前可见数量上限,不为了补齐数量发起第二次请求。 + +总开关关闭时,规则行的 enabled switch 仍反映每条规则自己的状态;用户仍可以编辑或启停单条规则,但 +`masterEnabled === false` 时 compiler 必须输出空数组。所有网站规则从 disabled 变为 enabled 时,无论总开关 +当前是否开启都必须确认,因为它可能在以后恢复总开关时生效。 + +空状态: + +- 标题:`No CSP rules` +- 说明:`Add only the websites where a script is blocked by CSP.` +- CTA:`Add rule` + +### 5.4 新增与编辑 + +使用响应式 shadcn `Sheet`;桌面从右侧打开,移动端占满可用宽度。字段顺序: + +1. **Websites**(必填) + - 接受以换行、逗号分隔的多个值。 + - 接受完整 HTTP(S) URL 或域名;完整 URL 只提取 hostname。 + - 每个合法值提交后成为可删除 badge。 + - 固定辅助文案:`All paths and subdomains are included.` +2. **Name**(可选,最多 80 字符) + - 留空时保存阶段自动生成,不要求一般用户先理解“规则名称”。 +3. **Enabled**(默认开)。 +4. **Advanced scope**(折叠) + - 选项 `All websites`;选择后隐藏普通域名输入。 + - 新建或编辑结果为启用的 `allSites` 规则时显示 `AlertDialog`,明确它会影响所有匹配的主框架和子框架 + 网络响应;确认前不得提交 service worker。 + - 从 `allSites` 切回 `domains` 时必须重新提供至少一个域名;从 `domains` 切到 `allSites` 只清空表单草稿, + Cancel 后仍恢复原规则。 + +底部固定 ActionBar:Cancel、Save rule。验证在 blur 与 submit 进行;一旦显示错误则实时重验。 +保存失败保留所有输入。 + +重复域名: + +- 同一条规则内自动去重。 +- 域名已存在于另一规则时显示非阻塞提示并允许保存,以满足“一个域名对应多条规则”。 + +### 5.5 操作反馈 + +- CRUD 或 switch 不做未经确认的乐观成功;等待 service worker 返回应用结果。 +- 完全成功:`Rule saved. Reload affected pages to apply it.` +- 逻辑状态已保存但 DNR 应用失败: + `Rule saved, but browser rules could not be updated. Retry before relying on it.` +- 删除、停用、总暂停成功同样提示重新加载;现有 document 的 CSP 不会被追溯恢复。 +- 总开关恢复时若存在启用的 `allSites` 规则,必须再次确认影响范围;确认取消时保持暂停状态。 +- 保存、启用、禁用、删除或总开关操作在 service worker 返回前不得显示成功 toast 或先改写为成功状态。 +- service worker 返回 apply error 时,toast 只能说明“已保存但未应用”,不能使用成功文案;表单在 storage error + 时保留输入,apply error 时关闭表单并显示规则已保存但未应用。 + +## 6. 域名语义 + +### 6.1 规范化 + +纯函数 `normalizeCspDomain(input)`: + +1. trim 空白;单个输入 token 不得包含换行或逗号。 +2. 以 `http://` 或 `https://` 开头的 token(scheme 比较不区分大小写)按完整 URL 解析:只取 + `URL.hostname`,忽略 path、query、hash 和合法端口,因此保存后的规则匹配该 hostname 的所有端口;出现 + username/password credentials 时拒绝。URL 的 scheme 必须是 HTTP(S)。 +3. 不带 scheme 的 token 只能是裸 hostname:不接受 path、query、hash、端口、credentials 或 `//` 前缀。 + 方括号包裹的 IPv6 是例外,允许其 canonical IPv6 形式。 +4. 通过 WHATWG `URL` API 得到小写 ASCII hostname;IDN 存储为 punycode。移除末尾根域点, + 但不删除 IPv6 的方括号。 +5. 普通 hostname 至少包含一个点;`localhost`、`abc123` 等单标签名称第一版拒绝。合法 IPv4 和规范 IPv6 + 不受此条限制。 +6. 拒绝空值、浏览器内部 URL、非法端口/hostname、普通输入中的 `*` 和 `*.example.com`。 +7. 单个规范域名最长 253 个 ASCII 字符;超过时返回字段错误,不截断。 + +字段解析的确定顺序是:先按换行或逗号拆 token,再对每个 token trim、规范化、去重,最后执行数量上限。 +空 token 忽略;如果用户输入非空文本后所有 token 都为空,返回“至少输入一个网站”的错误。规范化失败的 +token 必须保留其字段位置,供 UI 就地标错;不得因为一个 token 失败而静默丢掉其他 token。 + +以下输入输出是第一版固定行为: + +| 输入 | 结果 | +| --- | --- | +| `https://Example.com:8443/a?q=1#x` | `example.com` | +| `example.com.` | `example.com` | +| `https://user:pass@example.com/` | 拒绝 credentials | +| `*.example.com` | 拒绝,并提示输入 `example.com`;系统已自动包含子域名 | +| `example.com/path` | 拒绝裸 hostname 中的 path | +| `localhost` | 拒绝单标签 hostname | +| `https://[2001:db8::1]/` | `[2001:db8::1]` | + +`*.example.com` 的错误提示必须说明:请输入 `example.com`,系统已自动包含子域名。 + +### 6.2 匹配 + +普通规则直接使用 DNR `requestDomains`。例如: + +| 保存值 | 匹配 | 不匹配 | +| --- | --- | --- | +| `example.com` | `example.com`、`a.example.com`、`a.b.example.com` 的所有路径 | `example.net`、`notexample.com` | +| `www.example.com` | `www.example.com` 及其子域名 | 根域 `example.com`、兄弟域名 `api.example.com` | + +“所有网站”在持久化模型中是明确的 `allSites` target,不存成普通域名 `*`。编译 DNR 时才转换成 +`condition.urlFilter = "*"`;只有 compiler 可以生成这个值,表单不得把它当作普通域名传给 service。 + +## 7. 持久化模型 + +计划新增 `src/app/repo/csp_rule.ts`,以一个带版本的 state 作为 `Repo` 实体,避免总开关与规则集合 +分开写入造成逻辑状态撕裂。DAO 名称、prefix 和 key 也固定,避免后续实现各自选择 storage key: + +```ts +export class CspRuleStateDAO extends Repo { + constructor() { + super("csp_rule"); + } + + getState(): Promise { + return this.get("state"); + } + + saveState(state: CspRuleState): Promise { + return this._save("state", state); + } +} +``` + +因此唯一持久化 key 为 `csp_rule:state`。该 DAO 不得调用 `enableCache()`:保存后必须从 storage 重新读取, +否则无法验证写入结果。`_save` 的现有 Promise 只表示 callback 已返回,不表示没有 +`chrome.runtime.lastError`;DAO 必须在写后重新读取并同时核对 `schemaVersion`、`revision` 和完整 state。 + +逻辑数据合同: + +```ts +type CspRuleTarget = + | { type: "domains"; domains: string[] } + | { type: "allSites" }; + +type CspRuleAction = { type: "removeCspHeaders" }; + +type CspRule = { + id: string; + name: string; + enabled: boolean; + target: CspRuleTarget; + action: CspRuleAction; + createdAt: number; + updatedAt: number; +}; + +type CspRuleState = { + schemaVersion: 1; + revision: number; + masterEnabled: boolean; + rules: CspRule[]; +}; +``` + +约束: + +- 默认 state 固定为 `{ schemaVersion: 1, revision: 0, masterEnabled: true, rules: [] }`。storage 没有 key + 时按该默认值运行,但不为了读取而写入;第一次真实用户变更才持久化 revision `1`。 +- 每次改变逻辑 state 的成功 mutation 使 revision 加 1;`retryApply` 不改变 revision;没有语义变化的 + mutation 不写 storage,也不调用 DNR。 +- `createdAt` 和 `updatedAt` 是 Unix epoch milliseconds。创建时两者相同;编辑或启停只更新 + `updatedAt`;数组顺序按第 3 节规则保持。 +- ID 使用仓库现有 UUID 工具,不引入新依赖。 +- service worker 是唯一写入者;Options 只能经 message client 修改。客户端不得发送完整替换后的 state,只能发送 + 第 8 节规定的操作和 patch。 +- 保存前重新执行全部结构和域名验证;UI 验证不能作为信任边界。 +- 保存失败或写后核对失败时,不调用 DNR,不更新 service 内存中的 confirmed state,并向 UI 返回 + `storage_write_failed`;编辑表单保留输入。若写入后 revision 已变化但读取不一致,必须把它视为失败并停止, + 不能再次盲写覆盖。 +- `schemaVersion` 缺失、不是 `1`,或 state 结构无法通过完整校验时,不删除或覆盖原数据;先尝试仅移除 ID 2001, + 再向 UI 返回 `unsupported_schema`,并禁用 CRUD,直到未来版本提供明确迁移。未知数据不能伪装成默认空 state。 + +完整结构校验至少包括:schemaVersion、revision 非负整数、布尔字段类型、每条规则的 id/name/enabled/target/action +类型、时间戳为有限整数、域名已 canonicalize、规则及域名上限。state 中出现未知 action 或 target 必须拒绝。 + +action 采用可判别联合只是稳定的数据边界,不代表预先实现其他 action。日后新增 `setCspHeader` 或通用 +header action 时,必须先定义同域冲突、优先级、商店说明、迁移和 UI;不能只向 union 加一个字符串。 + +## 8. 服务与 DNR 同步 + +### 8.1 模块职责 + +计划文件与单一职责: + +| 计划文件 | 职责 | +| --- | --- | +| `src/app/repo/csp_rule.ts` | state 类型、默认值、读取与保存 | +| `src/pkg/utils/csp_domain.ts` | 域名规范化和验证纯函数 | +| `src/app/service/service_worker/csp_rule_compiler.ts` | 纯函数:state → owned DNR rules | +| `src/app/service/service_worker/csp_rule.ts` | CRUD、总开关、规则上限、保存后 reconcile | +| `src/app/service/service_worker/client.ts` | `CspRuleClient` | +| `src/pages/options/routes/Tools/sections/CspRulesSection.tsx` | 工具卡、清单、状态 | +| `src/pages/options/routes/Tools/sections/CspRuleSheet.tsx` | 新增/编辑表单 | + +`ServiceWorkerManager` 创建 DAO、compiler/applier 与 service,按构造函数注入;service 不在方法内 `new` DAO。 +建议的构造边界为 `CspRuleService(group, messageQueue, stateDAO, compiler, applier)`;具体参数名可按现有 +代码风格调整,但每个依赖必须可在单元测试中替换。compiler 不得读取 storage 或调用 Chrome API,applier +不得修改逻辑 state。 + +所有 mutation 与随后的 reconcile 必须在 service 内串行执行,避免两个 Options 页面或快速连续操作互相覆盖。 +串行队列必须覆盖“读取 confirmed state → 应用 patch → 验证/保存 → 重新读取 → compile → updateDynamicRules +→ 生成结果”的整个区间,不能只锁住 storage 写入。每个请求从队列执行时的 confirmed state 开始,不接受客户端 +上传的完整 state。 + +### 8.1.1 Message/client contract + +service 在 `this.api.group("cspRule")` 上注册以下方法;名称和参数形状固定,返回类型如下。`baseRevision` 是 +客户端打开表单或读取列表时看到的 revision;除 `retryApply` 外所有 mutation 都必须携带它。 + +```ts +type CspRuleTargetInput = + | { type: "domains"; domains: string[] } + | { type: "allSites" }; + +type CspRuleCreateInput = { + baseRevision: number; + name?: string; + enabled: boolean; + target: CspRuleTargetInput; +}; + +type CspRuleUpdateInput = { + baseRevision: number; + id: string; + patch: Partial>; +}; + +type CspRuleEnabledInput = { baseRevision: number; id: string; enabled: boolean }; +type CspRuleDeleteInput = { baseRevision: number; id: string }; +type CspMasterEnabledInput = { baseRevision: number; enabled: boolean }; + +type CspRuleSnapshot = { state: CspRuleState; apply: CspApplyStatus }; +type CspMutationResult = CspRuleSnapshot & { outcome: "applied" | "apply-error" }; +``` + +| method | input | behavior | +| --- | --- | --- | +| `getState` | none | 返回当前 snapshot;service 初始化未完成前等待初始化结果,不返回空 state | +| `createRule` | `CspRuleCreateInput` | 校验、追加一条规则、保存并 reconcile | +| `updateRule` | `CspRuleUpdateInput` | 只修改指定规则的 name/target;patch 为空拒绝,enabled 用单独方法 | +| `deleteRule` | `CspRuleDeleteInput` | 删除指定 id;id 不存在返回 `not_found`,不得删除其他规则 | +| `setRuleEnabled` | `CspRuleEnabledInput` | 只修改指定规则 enabled;目标为 `allSites` 且由 off 变 on 时必须先由 UI 确认 | +| `setMasterEnabled` | `CspMasterEnabledInput` | 只修改总开关;从 off 变 on 且有启用的 `allSites` 规则时,UI 必须先确认 | +| `retryApply` | none | 对当前已保存 state 重新 reconcile,不改变 revision | + +`updateRule.patch` 只允许包含 `name` 和 `target`;字段出现时值必须完整且非 `undefined`,`target` 必须整体 +替换为一个已验证的 `domains` 或 `allSites` target。客户端不得通过空 patch、部分 domains 数组或隐藏字段 +修改 `id`、`enabled`、`action`、时间戳或数组顺序。 + +`CspRuleClient` 放在现有 `src/app/service/service_worker/client.ts`,继承 `Client` 并使用 prefix +`"serviceWorker/cspRule"`;Options 通过现有 global `message` 建立它,不直接构造 `Server` 或访问 Chrome +storage/DNR。service worker 的 `Server` prefix 当前为 `"serviceWorker"`,因此上述 group 名称最终对应这些 +message actions。 + +如果请求的 `baseRevision` 不等于队列执行时的 revision,返回 `revision_conflict` 和当前 snapshot,不写入、不 +调用 DNR;UI 必须重新读取并提示用户未覆盖其他页面的修改。service 仍必须在执行前后复核 revision,因为 +客户端检查不是信任边界。 + +service 错误必须保持可序列化并包含稳定 `code`,至少包括 `invalid_input`、`not_found`、`revision_conflict`、 +`storage_write_failed` 和 `unsupported_schema`;字段错误额外包含 `path` 与 `messageKey`。当前 message server +会把对象错误序列化为字符串,因此 `CspRuleClient` 必须解析这些 payload,而不能按英文错误文本分支。若实现 +发现当前 transport 无法保留这些字段,必须先扩展 transport 并为其添加测试,再接入 UI。 +`CspRuleService` 通过 `Group.on(...)` 注册: + +- `getState` +- `createRule` +- `updateRule` +- `deleteRule` +- `setRuleEnabled` +- `setMasterEnabled` +- `retryApply` + +每个变更接口返回最新 state 和: + +```ts +type CspApplyStatus = + | { state: "applied"; revision: number; appliedAt: number } + | { + state: "error"; + code: "dnr_apply_failed"; + desiredRevision: number; + lastAppliedRevision?: number; + message: string; + }; +``` + +`applied` 表示 DNR 中 ID 2001 的实际内容等于 `compileCspRules(state)`;`error` 表示逻辑 state 已保存但 DNR +内容未知或仍是上一 revision。`lastAppliedRevision` 只填写 service 明确成功应用过的 revision;首次应用失败 +或无法确认已有规则时省略,不能猜测。apply status 是内存状态,不写入 `csp_rule:state`;service 重启后必须 +重新 reconcile。 + +service 每次成功保存后,以及 DNR 应用失败后,都通过 `IMessageQueue.publish` 发布同一个最新 snapshot,topic +固定为 `cspRule/stateChanged`。Options 端的 `CspRuleClient` 订阅该 topic,收到新 revision 后替换列表;较旧 +revision 的事件必须丢弃。请求响应和广播都使用相同 snapshot,避免两个 Options 页面展示不同的 apply status。 + +### 8.2 DNR 编译 + +第一版固定移除以下 response headers: + +- `content-security-policy` +- `content-security-policy-report-only` +- `x-content-security-policy` +- `x-webkit-csp` + +仅匹配 `main_frame` 与 `sub_frame`。 + +编译步骤: + +1. `masterEnabled === false` 或没有 enabled 规则:产出空数组。 +2. 任一 enabled rule 为 `allSites`:产出 1 条 `urlFilter: "*"` 规则。 +3. 否则合并所有 enabled `domains`,去重并按 ASCII 升序后产出 1 条 `requestDomains` 规则。 +4. 两种非空结果都必须完全符合以下 shape;`responseHeaders` 而不是 `requestHeaders` 是固定合同: + + ```ts + { + id: 2001, + priority: 1, + action: { + type: "modifyHeaders", + responseHeaders: [ + { header: "content-security-policy", operation: "remove" }, + { header: "content-security-policy-report-only", operation: "remove" }, + { header: "x-content-security-policy", operation: "remove" }, + { header: "x-webkit-csp", operation: "remove" }, + ], + }, + condition: { + resourceTypes: ["main_frame", "sub_frame"], + // domains target: requestDomains: ["example.com", ...] + // allSites target: urlFilter: "*" + }, + } + ``` + +5. 不生成 regex,不暴露 priority;priority 固定为 `1`。compiler 必须是纯函数,并对等价 state 产生稳定的 + 深相等结果;第一版只有一个幂等 action。 + +脚本安装流程当前使用 dynamic rule ID `1`(初始化清理)和失败回退时的 ID `2`,详见 +[`src/app/service/service_worker/script.ts`](../../src/app/service/service_worker/script.ts)。CSP 第一版只拥有 +dynamic rule ID `2001`;该 ID 必须定义为单一常量,不能散落为 magic number: + +```ts +await chrome.declarativeNetRequest.updateDynamicRules({ + removeRuleIds: [2001], + addRules: compiledRules, +}); +``` + +即使 `compiledRules` 为空,也只允许调用 `{ removeRuleIds: [2001], addRules: [] }` 清理本功能的旧规则;不得按 +范围清理,也不得移除其他模块的 dynamic rules。必须同时处理 Promise rejection 和 callback 中的 +`chrome.runtime.lastError`,并在错误时保留已有 DNR 规则。官方 API 保证同一次 `updateDynamicRules` 的 +remove/add 原子执行;失败时现有 DNR 保持不变。 + +### 8.3 状态一致性 + +逻辑 state 是用户意图的唯一来源,DNR 是可重建派生状态: + +1. 先验证新 state; +2. 保存并重新读取相同 revision;若核对失败,停止且不调用 DNR,UI 显示“未保存”; +3. compiler 从已确认保存的 state 重建全部 owned rules; +4. 单次原子调用替换 ID 2001; +5. 成功返回 `applied`; +6. DNR 失败不吞异常、不谎报成功,返回 desired/last-applied revision 的 `error` 并保留“Retry”;总暂停失败时 + 不得显示“已暂停”,而要明确浏览器仍可能运行上一 revision。`error` 中的 `desiredRevision` 必须等于 + 已确认写入的 state revision; +7. service worker 初始化必须在注册 `cspRule/*` handlers 后完成一次 load/reconcile。初始化成功前,handler 等待 + 同一个 `ready` Promise;初始化失败则所有请求得到可见的 `unsupported_schema` 或 `storage_write_failed`, + 不返回默认空 state。一并修复扩展升级或浏览器重启后遗留、缺失或内容不一致的 ID 2001。 + +如果 service worker 启动时没有 `csp_rule:state`,先使用默认 state 编译为空数组并清理 ID 2001;清理失败必须 +显示 apply error,因为旧的 CSP 修改规则可能仍然生效。若 state 已有 `masterEnabled: false` 或没有启用规则, +成功 reconcile 的结果仍是 `applied`(revision 对应空 DNR),不是一个特殊的 paused apply error。 + +这个顺序接受“state 已保存、DNR 暂未应用”的短暂状态,代价是 UI 必须持续显示错误。它避免更复杂且仍可能 +二次失败的跨 `chrome.storage` / DNR 伪事务回滚。 + +## 9. 平台、权限与审核 + +当前 [`src/manifest.json`](../../src/manifest.json) 已包含: + +- `declarativeNetRequest` +- `host_permissions: [""]` + +因此第一版不新增权限,也不加入 `declarativeNetRequestWithHostAccess`。功能必须完全由包内代码和本地用户规则 +决定,不获取远程逻辑或远程规则。实现必须不扩大 `host_permissions`,并在 manifest 未包含这两项能力时让构建 +或启动检查明确失败,而不是静默显示“已应用”。 + +浏览器构建目标以 [`rspack.config.ts`](../../rspack.config.ts) 为准:Chrome `>= 120`、Edge `>= 120`、Firefox +`>= 136`。本规格中的“当前目标浏览器”均指这三个目标,不得用开发者本机的更高版本替代矩阵中的验证。 + +平台依据: + +- [Chrome DNR API](https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest): + `requestDomains` 自动匹配列出域名的子域名;dynamic rule 更新是原子的;`modifyHeaders` 属于最多 + 5,000 条的 unsafe dynamic rules,而本功能第一版固定只用 1 条。 +- [MDN DNR RuleCondition](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/declarativeNetRequest/RuleCondition): + canonical domain 应为小写 ASCII,IDN 使用 punycode。 +- [MDN updateDynamicRules](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/declarativeNetRequest/updateDynamicRules): + Firefox 128 起 dynamic/session 上限分离;仓库打包最低 Firefox 136。 +- [Chrome Web Store policies](https://developer.chrome.com/docs/webstore/program-policies/policies): + 提交代码应让审核者清楚辨认全部功能。因此商店说明、截图和审核备注必须明确本地按域名移除 CSP 的行为。 + +## 10. SPA 与生命周期语义 + +- CSP 来自主文档或子文档的网络响应。规则在该响应进入渲染引擎前移除 response header;第一版只匹配 + `main_frame` 和 `sub_frame`,不匹配 script、xhr、image 等子资源响应。 +- 同 document 内的 `pushState` / `replaceState` 不会重新请求主 document;因第一版按域名而非 path, + UI 不会出现“地址变了所以规则该重新匹配”的误导。 +- 跨域导航或实际 reload 会产生新的 document request,并按新请求域名匹配。 +- 对已加载页面新增/停用规则不会追溯改变现有 document 或子文档;必须 reload 或触发新的 document request。 +- `history.pushState` / `replaceState` 只改变当前 document 的 URL,不会因为 path 改变而重新应用或撤销同一域名 + 的规则;跨域的实际导航才按新请求重新匹配。 +- 规则对匹配域名生效,不检查是否存在 ScriptCat 脚本。这是设计,不是缺陷。 + +## 11. 状态、安全与无障碍要求 + +- Light、dark 两主题都只能使用 design tokens;不写 raw palette/hex 或 inline color。 +- 首次加载、空、应用中、应用失败、成功反馈全部可见。 +- 错误显示可操作摘要;原始错误放 `font-mono` 详情,不只写 console。 +- 所有 icon-only 按钮有 `aria-label`;自定义交互有 keyboard focus ring。 +- Switch 同时有文字状态,不能只靠颜色表示启用。 +- Sheet 使用 Radix 的 focus trap、Esc 和 return-focus。 +- 异步摘要使用 `role="status"`;应用进度使用 `role="progressbar"` 或按钮内 `Loader2`。 +- 移动端主要 tap target 不小于约 44px;德语/俄语等长文案可换行,不裁切。 +- 所有网站范围用文字、图标和语义色三重提示,满足非颜色线索要求。 + +## 12. 验收标准 + +### 12.0 测试边界与文件归属 + +按职责放置测试,避免把 service、compiler 和 UI 的失败混在一个大 mock 中: + +| 目标 | 计划测试文件 | 必须隔离的依赖 | +| --- | --- | --- | +| 规范化与 token 解析 | `src/pkg/utils/csp_domain.test.ts` | 只使用纯函数,不依赖 Chrome | +| DAO 默认值、round-trip、schema 校验 | `src/app/repo/csp_rule.test.ts` | 使用现有 storage mock,并模拟 `runtime.lastError` | +| state → DNR rule | `src/app/service/service_worker/csp_rule_compiler.test.ts` | 不调用 Chrome API,深相等断言完整 rule shape | +| mutation、队列、reconcile、错误 | `src/app/service/service_worker/csp_rule.test.ts` | 注入 fake DAO/compiler/applier/message queue | +| 工具卡和表单 | `src/pages/options/routes/Tools/sections/CspRulesSection.test.tsx`、`src/pages/options/routes/Tools/sections/CspRuleSheet.test.tsx` | 注入 `CspRuleClient`,不访问真实 service worker | + +当前 Chrome mock 只覆盖 session 规则;实现 CSP 测试时先扩展 mock 的最小 +`updateDynamicRules`/`getDynamicRules` 能力并覆盖原子失败行为。不要为了 CSP 测试直接改写现有脚本安装规则的 +测试。applier 测试必须证明:失败时 remove/add 都不生效,成功时只改变 ID 2001。 + +### 12.1 领域与编译单元测试 + +测试名称遵守仓库中文 BDD 风格,至少覆盖: + +- `输入完整网址时只保存规范化 hostname` +- `输入 IDN 时保存 punycode 并可由 requestDomains 使用` +- `输入星号和单标签 hostname 时给出明确错误` +- `同一规则的重复域名被去重` +- `规则数、单规则域名数和全局不同域名数超过第一版上限时拒绝保存` +- `多个规则的域名合并为一条 DNR 规则` +- `同一域名出现在多条规则时仍只编译一次` +- `存在所有网站规则时编译为 urlFilter 星号` +- `总开关关闭时编译为空规则` +- `编译结果只生成 ID 2001、priority 1、modifyHeaders responseHeaders 四个 CSP 头且只匹配 main_frame/sub_frame` +- `更新只拥有 ID 2001 且不会移除脚本安装 dynamic rule ID 2` +- `DNR 更新失败时保存 state 并返回可重试错误` +- `storage 写后 revision 核对失败时不调用 DNR` +- `并发 mutation 按保存与 reconcile 的完整顺序串行执行` +- `未知 schema 保留数据并移除 owned DNR rule` +- `service worker 初始化时按持久化 state 重建规则` +- `缺失 storage key 时使用 revision 0 默认 state 且不写入 storage` +- `写后重新读取的完整 state 与候选 state 不一致时返回 storage error 且不调用 DNR` +- `baseRevision 过期时返回 revision conflict 且不覆盖其他页面的修改` +- `retryApply 使用当前已保存 state 且不增加 revision` +- `应用失败后 lastAppliedRevision 只记录明确成功的上一 revision` +- `较旧的 stateChanged 广播不会覆盖较新的 UI state` + +### 12.2 UI 组件测试 + +- 首次加载显示 skeleton,失败显示错误与 Retry。 +- 空状态 CTA 打开新增 Sheet。 +- 粘贴完整 URL 后显示规范化域名 badge 和“包含子域名”说明。 +- blur/submit 错误就地显示;保存失败不清空表单。 +- 桌面规则行与移动规则卡都能启用、编辑、删除。 +- 第 21 条起由“Show more”逐批显示,每批 20 条。 +- 所有网站启用、以及恢复包含所有网站规则的总开关,都出现明确确认。 +- 成功、部分失败都显示正确 toast;成功文案提醒 reload。 +- 两主题与 390px 宽度不出现横向滚动、遮挡或低于要求的主要 tap target。 +- 总开关暂停时逐条 enabled 状态保持可见;恢复包含启用 `allSites` 规则时取消确认不会改变 state。 +- revision conflict、storage error、unsupported schema 都显示可翻译的可操作错误,不按英文错误文本分支。 + +### 12.3 真实扩展验证 + +按 [verification.md](../verification.md) 使用一次性本地 HTTP fixture,不把 scratch 脚本提交进测试套件: + +1. fixture 返回含 `Content-Security-Policy` 的 HTML,证明没有规则时 `eval/new Function` 被阻止。 +2. 在工具页为该 hostname 新增规则,reload 后证明 header 已移除且测试代码可执行。 +3. 在同一 document 执行 `history.pushState` 到另一 path,证明用户不需新增路径规则。 +4. 停用规则或总暂停,reload 后证明 CSP 恢复。 +5. 页面没有任何 ScriptCat 脚本时,证明 header 仍按域名规则移除。 +6. `` fixture 仍受限制,并与帮助文案一致。 +7. Chrome/Edge 当前目标版本与 Firefox 136+ 各跑一次;Firefox 重启后规则仍按持久化 state reconcile。 +8. 在 light/dark、桌面与 390px 移动视口各检查一次核心流程和 keyboard focus。 + +DNR 故意失败的“已保存但未应用”路径由第 12.1 节的注入 applier 测试和第 12.2 节的 UI 测试证明;真实 +浏览器验证不添加生产环境故障开关。若浏览器手动验证无法观察到该路径,报告必须注明该限制,而不是声称已 +完成真实扩展验证。 + +## 13. 实施顺序与安全暂停点 + +1. **模型与规范化**:先写失败测试,再实现 `CspRuleStateDAO` 与 `normalizeCspDomain`;验收默认 state、 + round-trip、所有输入边界和 1–100/1,000 上限。此步不注册 service handler,不改变浏览行为。 +2. **compiler 与 DNR applier**:先写 ID 所有权、完整 rule shape、合并、空规则、原子失败测试;实现后仍无 + service worker 入口,默认空 state 不改变浏览行为。 +3. **service 与 client**:构造函数注入 DAO/compiler/applier/queue,注册 Group methods;验证初始化 reconcile、 + baseRevision 冲突、所有 CRUD、apply error、Retry 和 stateChanged 广播。 +4. **工具卡只读状态**:接入 `SettingsLayout` 分类、loading/applied/paused/error/load-error,不开放写操作; + 此步结束可从真实 service worker 读取默认空 state。 +5. **新增/编辑/启停/删除**:逐个交付并各自补 UI 测试;任何步骤结束都保持可构建、可测试,失败时不产生 + 未确认的成功反馈。 +6. **所有网站高级范围**:最后加入 `allSites` 持久化、确认门槛、恢复总开关确认和回归测试,避免高风险能力 + 先于安全文案落地。 +7. **i18n 与真实扩展验证**:按翻译规范把新增 key 写入 8 个现有 locale 的 `tools` namespace,运行 + `pnpm run lint`、`pnpm run typecheck`、相关 Vitest,再按验证指南跑浏览器矩阵;不要把“新增 locale”误写成 + “补齐 locale”。 +8. **商店与发布说明**:明确用户主动、按域名、本地规则、会削弱 CSP;附 UI 截图、不新增权限的说明,并在 + 发布前重新核对本规格的 shipped/target 状态。 + +## 14. 决策记录 + +### D1. 域名优先,拒绝第一版 URL pattern + +触发:PR #1348 的路径/whistle pattern 方案遇到 SPA URL 变化和 domain/subdomain 语义不清。 +根因是结构性的:CSP 绑定 document 响应,而不是 SPA 当前显示的 path。选择 canonical domain + +`requestDomains`,拒绝自建 pattern matcher,代价是第一版不能只对某个 path 生效。由域名规范化测试、 +compiler 测试与 SPA fixture 强制。 + +### D2. 总开关只暂停规则,不代表全局移除 + +触发:cyfung1031 明确反对 Tampermonkey 式单一全局开关,同时又要求能快速停止规则。 +选择“总暂停 + 域名清单”;拒绝首屏“Disable CSP everywhere”,代价是第一次使用多一步添加网站。 +所有网站能力留在高级范围并二次确认。 + +### D3. 逻辑规则与 DNR 规则分离 + +触发:一条规则需支持多域名、同一域名可属于多规则,而浏览器规则配额和 ID 属于全扩展共享资源。 +选择保存用户可理解的逻辑规则,再按 action 合并为至多 1 条 owned DNR rule;拒绝一条逻辑规则直接映射 +一条 DNR rule,代价是 UI 不能把浏览器 rule ID 当作用户规则 ID。由 ID 2001 所有权和合并测试强制。 + +### D4. 第一版只移除 CSP 头 + +触发:讨论提出 modify header、插件转发和云控,但同时担忧产品定位与审核。 +选择固定 `removeCspHeaders`,拒绝通用 header 工具、远程规则和优先级 UI,代价是后续 action 需要新的 +spec 与迁移。这样保持功能可辨认、权限不扩张,也符合“按实际需要扩充”。 + +### D5. 保存用户意图,显式暴露 DNR 应用失败 + +触发:storage 与 DNR 没有跨 API 事务,PR #1348 的 catch/log 会造成 UI 误以为规则已生效。 +选择 state 先保存、DNR 后 reconcile、失败持续可见且可 Retry;拒绝吞错或复杂回滚,代价是允许一个明确标记的 +“已保存但未应用”状态。service worker 初始化会自动重试。 + +### D6. Message 只提交操作,使用 revision 防止多页面覆盖 + +触发:两个 Options 页面或快速连续操作可能基于不同列表快照提交完整 state,后写入的请求会静默覆盖前一个 +页面的规则。根因是消息 transport 只负责传输,不提供 state merge 或并发控制。选择 service worker 内串行 +执行操作、以 `baseRevision` 拒绝过期请求,并通过 `cspRule/stateChanged` 广播最新 snapshot;拒绝让 client +上传完整 state 后由 service 猜测如何合并,代价是过期表单需要重新读取并由用户重试。这个约束由 service +mutation、revision conflict 和旧广播丢弃测试强制。 From fb767bb958c02801772d622e75f1ef98113aea3c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:43:55 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20=E5=A2=9E=E5=8A=A0=20CSP=20?= =?UTF-8?q?=E8=A7=84=E5=88=99=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/specs/csp-rule-management.md | 3 +- .../declarativ_net_request.ts | 36 ++ src/app/repo/csp_rule.test.ts | 69 +++ src/app/repo/csp_rule.ts | 159 ++++++ src/app/service/service_worker/client.ts | 82 ++++ .../service/service_worker/csp_rule.test.ts | 156 ++++++ src/app/service/service_worker/csp_rule.ts | 367 ++++++++++++++ .../service_worker/csp_rule_compiler.test.ts | 98 ++++ .../service_worker/csp_rule_compiler.ts | 93 ++++ src/app/service/service_worker/index.ts | 13 + src/locales/de-DE/tools.json | 67 ++- src/locales/en-US/tools.json | 67 ++- src/locales/ja-JP/tools.json | 67 ++- src/locales/ru-RU/tools.json | 67 ++- src/locales/tr-TR/tools.json | 67 ++- src/locales/vi-VN/tools.json | 67 ++- src/locales/zh-CN/tools.json | 67 ++- src/locales/zh-TW/tools.json | 67 ++- src/pages/options/routes/Tools/categories.ts | 3 +- src/pages/options/routes/Tools/index.test.tsx | 17 +- src/pages/options/routes/Tools/index.tsx | 2 + .../Tools/sections/CspRuleSheet.test.tsx | 64 +++ .../routes/Tools/sections/CspRuleSheet.tsx | 255 ++++++++++ .../Tools/sections/CspRulesSection.test.tsx | 136 ++++++ .../routes/Tools/sections/CspRulesSection.tsx | 458 ++++++++++++++++++ src/pkg/utils/csp_domain.test.ts | 42 ++ src/pkg/utils/csp_domain.ts | 110 +++++ 27 files changed, 2686 insertions(+), 13 deletions(-) create mode 100644 src/app/repo/csp_rule.test.ts create mode 100644 src/app/repo/csp_rule.ts create mode 100644 src/app/service/service_worker/csp_rule.test.ts create mode 100644 src/app/service/service_worker/csp_rule.ts create mode 100644 src/app/service/service_worker/csp_rule_compiler.test.ts create mode 100644 src/app/service/service_worker/csp_rule_compiler.ts create mode 100644 src/pages/options/routes/Tools/sections/CspRuleSheet.test.tsx create mode 100644 src/pages/options/routes/Tools/sections/CspRuleSheet.tsx create mode 100644 src/pages/options/routes/Tools/sections/CspRulesSection.test.tsx create mode 100644 src/pages/options/routes/Tools/sections/CspRulesSection.tsx create mode 100644 src/pkg/utils/csp_domain.test.ts create mode 100644 src/pkg/utils/csp_domain.ts diff --git a/docs/specs/csp-rule-management.md b/docs/specs/csp-rule-management.md index 5f537975b..46e223ea1 100644 --- a/docs/specs/csp-rule-management.md +++ b/docs/specs/csp-rule-management.md @@ -1,6 +1,7 @@ # CSP 规则管理功能规格 -> 状态:目标规格,尚未在当前分支实现。最后核对日期:2026-07-17。 +> 状态:实现已提交候选分支;自动化测试与生产构建已通过,真实扩展的 Chrome、Edge、Firefox 手动验证仍未完成。 +> 最后核对日期:2026-07-17。 > > 实现本规格前仍须遵守 [开发规范](../develop.md)、[设计系统](../design.md)、 > [测试规范](../references/develop-testing.md)、[翻译规范](../translation.md)和 diff --git a/packages/chrome-extension-mock/declarativ_net_request.ts b/packages/chrome-extension-mock/declarativ_net_request.ts index af50dae54..a51fac154 100644 --- a/packages/chrome-extension-mock/declarativ_net_request.ts +++ b/packages/chrome-extension-mock/declarativ_net_request.ts @@ -2,6 +2,8 @@ export default class DeclarativeNetRequest { MAX_NUMBER_OF_SESSION_RULES = 5000; private _sessionRules: chrome.declarativeNetRequest.Rule[] = []; + private _dynamicRules: chrome.declarativeNetRequest.Rule[] = []; + dynamicUpdateError: string | undefined; HeaderOperation = { APPEND: "append", @@ -74,4 +76,38 @@ export default class DeclarativeNetRequest { getSessionRules(): Promise { return Promise.resolve([...this._sessionRules]); } + + updateDynamicRules(options: chrome.declarativeNetRequest.UpdateRuleOptions, callback?: () => void): Promise { + return new Promise((resolve, reject) => { + if (this.dynamicUpdateError) { + const message = this.dynamicUpdateError; + this.dynamicUpdateError = undefined; + (chrome.runtime as typeof chrome.runtime & { lastError?: chrome.runtime.LastError }).lastError = { message }; + callback?.(); + delete (chrome.runtime as typeof chrome.runtime & { lastError?: chrome.runtime.LastError }).lastError; + reject(new Error(message)); + return; + } + const removeRuleIds = options.removeRuleIds ?? []; + const rules = this._dynamicRules.filter((rule) => !removeRuleIds.includes(rule.id)); + for (const newRule of options.addRules ?? []) { + const index = rules.findIndex((rule) => rule.id === newRule.id); + if (index === -1) rules.push(newRule); + else rules[index] = newRule; + } + this._dynamicRules = rules; + callback?.(); + resolve(); + }); + } + + getDynamicRules(): Promise { + return Promise.resolve([...this._dynamicRules]); + } + + resetMock() { + this._sessionRules = []; + this._dynamicRules = []; + this.dynamicUpdateError = undefined; + } } diff --git a/src/app/repo/csp_rule.test.ts b/src/app/repo/csp_rule.test.ts new file mode 100644 index 000000000..bfd6305f6 --- /dev/null +++ b/src/app/repo/csp_rule.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + CspRuleStateDAO, + CspRuleValidationError, + DEFAULT_CSP_RULE_STATE, + type CspRuleState, + validateCspRuleState, +} from "./csp_rule"; + +function rule(id: string, domains: string[]) { + return { + id, + name: id, + enabled: true, + target: { type: "domains" as const, domains }, + action: { type: "removeCspHeaders" as const }, + createdAt: 1, + updatedAt: 1, + }; +} + +describe("CspRuleStateDAO", () => { + beforeEach(async () => { + await chrome.storage.local.clear(); + }); + + it("缺失 storage key 时使用 revision 0 默认 state 且不写入 storage", async () => { + const dao = new CspRuleStateDAO(); + expect(await dao.getState()).toBeUndefined(); + expect(await chrome.storage.local.get()).toEqual({}); + expect(DEFAULT_CSP_RULE_STATE).toEqual({ schemaVersion: 1, revision: 0, masterEnabled: true, rules: [] }); + }); + + it("保存后重新读取完整 state 并完成 round-trip", async () => { + const dao = new CspRuleStateDAO(); + const state: CspRuleState = { ...DEFAULT_CSP_RULE_STATE, revision: 1 }; + expect(await dao.saveState(state)).toEqual(state); + expect(await dao.getState()).toEqual(state); + }); + + it("未知 schema 不会通过完整结构校验", () => { + expect(() => validateCspRuleState({ schemaVersion: 2 })).toThrowError(CspRuleValidationError); + }); + + it("规则数、单规则域名数和全局不同域名数超过上限时拒绝保存", () => { + const tooManyRules = Array.from({ length: 101 }, (_, index) => rule(`rule-${index}`, [`${index}.example.com`])); + expect(() => validateCspRuleState({ ...DEFAULT_CSP_RULE_STATE, rules: tooManyRules })).toThrowError( + CspRuleValidationError + ); + + const tooManyDomains = Array.from({ length: 101 }, (_, index) => `${index}.example.com`); + expect(() => + validateCspRuleState({ ...DEFAULT_CSP_RULE_STATE, rules: [rule("rule", tooManyDomains)] }) + ).toThrowError(CspRuleValidationError); + + const uniqueDomains = Array.from({ length: 11 }, (_, ruleIndex) => + rule( + `rule-${ruleIndex}`, + Array.from( + { length: ruleIndex === 10 ? 1 : 100 }, + (_, domainIndex) => `${ruleIndex * 100 + domainIndex}.example.com` + ) + ) + ); + expect(() => validateCspRuleState({ ...DEFAULT_CSP_RULE_STATE, rules: uniqueDomains })).toThrowError( + CspRuleValidationError + ); + }); +}); diff --git a/src/app/repo/csp_rule.ts b/src/app/repo/csp_rule.ts new file mode 100644 index 000000000..5aa0a0ec8 --- /dev/null +++ b/src/app/repo/csp_rule.ts @@ -0,0 +1,159 @@ +import { normalizeCspDomain } from "@App/pkg/utils/csp_domain"; +import { Repo } from "./repo"; + +export const CSP_RULE_SCHEMA_VERSION = 1 as const; +export const MAX_CSP_RULES = 100; +export const MAX_CSP_DOMAINS_PER_RULE = 100; +export const MAX_CSP_UNIQUE_DOMAINS = 1000; + +export type CspRuleTarget = { type: "domains"; domains: string[] } | { type: "allSites" }; + +export type CspRuleAction = { type: "removeCspHeaders" }; + +export type CspRule = { + id: string; + name: string; + enabled: boolean; + target: CspRuleTarget; + action: CspRuleAction; + createdAt: number; + updatedAt: number; +}; + +export type CspRuleState = { + schemaVersion: typeof CSP_RULE_SCHEMA_VERSION; + revision: number; + masterEnabled: boolean; + rules: CspRule[]; +}; + +export class CspRuleValidationError extends Error { + constructor( + public readonly path: string, + public readonly messageKey: string + ) { + super(messageKey); + this.name = "CspRuleValidationError"; + } +} + +export class CspRuleStorageError extends Error { + constructor() { + super("storage_write_failed"); + this.name = "CspRuleStorageError"; + } +} + +export const DEFAULT_CSP_RULE_STATE: CspRuleState = { + schemaVersion: CSP_RULE_SCHEMA_VERSION, + revision: 0, + masterEnabled: true, + rules: [], +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function validateTarget(target: unknown, path: string): target is CspRuleTarget { + if (!isRecord(target) || (target.type !== "domains" && target.type !== "allSites")) { + throw new CspRuleValidationError(path, "target_invalid"); + } + if (target.type === "allSites") return true; + if (!Array.isArray(target.domains) || target.domains.length < 1 || target.domains.length > MAX_CSP_DOMAINS_PER_RULE) { + throw new CspRuleValidationError(`${path}.domains`, "domain_count_invalid"); + } + const domains = new Set(); + for (const [index, domain] of target.domains.entries()) { + if (typeof domain !== "string" || normalizeCspDomain(domain) !== domain) { + throw new CspRuleValidationError(`${path}.domains[${index}]`, "domain_invalid"); + } + if (domains.has(domain)) throw new CspRuleValidationError(`${path}.domains[${index}]`, "domain_duplicate"); + domains.add(domain); + } + return true; +} + +export function validateCspRuleState(value: unknown): asserts value is CspRuleState { + if (!isRecord(value) || value.schemaVersion !== CSP_RULE_SCHEMA_VERSION) { + throw new CspRuleValidationError("schemaVersion", "unsupported_schema"); + } + const revision = value.revision; + const masterEnabled = value.masterEnabled; + const rules = value.rules; + if (typeof revision !== "number" || !Number.isInteger(revision) || revision < 0) { + throw new CspRuleValidationError("revision", "revision_invalid"); + } + if (typeof masterEnabled !== "boolean") { + throw new CspRuleValidationError("masterEnabled", "boolean_invalid"); + } + if (!Array.isArray(rules) || rules.length > MAX_CSP_RULES) { + throw new CspRuleValidationError("rules", "rule_count_invalid"); + } + + const ids = new Set(); + const uniqueDomains = new Set(); + for (const [index, rawRule] of rules.entries()) { + const path = `rules[${index}]`; + if (!isRecord(rawRule)) throw new CspRuleValidationError(path, "rule_invalid"); + const rule = rawRule; + const target = rule.target; + if (typeof rule.id !== "string" || !rule.id || ids.has(rule.id)) { + throw new CspRuleValidationError(`${path}.id`, "rule_id_invalid"); + } + ids.add(rule.id); + if (typeof rule.name !== "string" || !rule.name.trim() || Array.from(rule.name).length > 80) { + throw new CspRuleValidationError(`${path}.name`, "rule_name_invalid"); + } + if (typeof rule.enabled !== "boolean") throw new CspRuleValidationError(`${path}.enabled`, "boolean_invalid"); + if (!isRecord(rule.action) || rule.action.type !== "removeCspHeaders") { + throw new CspRuleValidationError(`${path}.action`, "action_invalid"); + } + if ( + typeof rule.createdAt !== "number" || + !Number.isFinite(rule.createdAt) || + !Number.isInteger(rule.createdAt) || + rule.createdAt < 0 + ) { + throw new CspRuleValidationError(`${path}.createdAt`, "timestamp_invalid"); + } + if ( + typeof rule.updatedAt !== "number" || + !Number.isFinite(rule.updatedAt) || + !Number.isInteger(rule.updatedAt) || + rule.updatedAt < 0 + ) { + throw new CspRuleValidationError(`${path}.updatedAt`, "timestamp_invalid"); + } + validateTarget(target, `${path}.target`); + if (isRecord(target) && target.type === "domains" && Array.isArray(target.domains)) { + for (const domain of target.domains) uniqueDomains.add(domain); + } + } + if (uniqueDomains.size > MAX_CSP_UNIQUE_DOMAINS) { + throw new CspRuleValidationError("rules", "unique_domain_count_invalid"); + } +} + +export class CspRuleStateDAO extends Repo { + constructor() { + super("csp_rule"); + } + + getState(): Promise { + return this.get("state"); + } + + async saveState(state: CspRuleState): Promise { + validateCspRuleState(state); + try { + await this._save("state", state); + const saved = await this.getState(); + if (!saved || JSON.stringify(saved) !== JSON.stringify(state)) throw new CspRuleStorageError(); + return saved; + } catch (error) { + if (error instanceof CspRuleStorageError) throw error; + throw new CspRuleStorageError(); + } + } +} diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 720564940..1c146586a 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -27,6 +27,18 @@ import type { TrashScript } from "@App/app/repo/trash_script"; import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value"; import { type TSetValuesParams } from "./value"; import type { LocalBackupExport } from "./synchronize"; +import type { + CspMutationResult, + CspRuleCreateInput, + CspRuleDeleteInput, + CspRuleEnabledInput, + CspRuleMasterEnabledInput, + CspRuleServiceError, + CspRuleSnapshot, + CspRuleUpdateInput, +} from "./csp_rule"; + +export type { CspRuleServiceError } from "./csp_rule"; export class ServiceWorkerClient extends Client { constructor(msgSender: MessageSend) { @@ -38,6 +50,76 @@ export class ServiceWorkerClient extends Client { } } +export function parseCspRuleError(error: unknown): CspRuleServiceError { + const payload = + typeof error === "string" + ? (() => { + try { + return JSON.parse(error) as unknown; + } catch { + return undefined; + } + })() + : error; + if (payload && typeof payload === "object" && "code" in payload) { + const code = payload.code; + if ( + code === "invalid_input" || + code === "not_found" || + code === "revision_conflict" || + code === "storage_write_failed" || + code === "unsupported_schema" + ) { + return payload as CspRuleServiceError; + } + } + return { code: "storage_write_failed" }; +} + +export class CspRuleClient extends Client { + constructor(msgSender: MessageSend) { + super(msgSender, "serviceWorker/cspRule"); + } + + private async request(action: string, data?: unknown): Promise { + try { + const result = await this.do(action, data); + if (result === undefined) throw new Error("empty response"); + return result; + } catch (error) { + throw parseCspRuleError(error); + } + } + + getState(): Promise { + return this.request("getState"); + } + + createRule(input: CspRuleCreateInput): Promise { + return this.request("createRule", input); + } + + updateRule(input: CspRuleUpdateInput): Promise { + return this.request("updateRule", input); + } + + deleteRule(input: CspRuleDeleteInput): Promise { + return this.request("deleteRule", input); + } + + setRuleEnabled(input: CspRuleEnabledInput): Promise { + return this.request("setRuleEnabled", input); + } + + setMasterEnabled(input: CspRuleMasterEnabledInput): Promise { + return this.request("setMasterEnabled", input); + } + + retryApply(): Promise { + return this.request("retryApply"); + } +} + export class ScriptClient extends Client { constructor(msgSender: MessageSend) { super(msgSender, "serviceWorker/script"); diff --git a/src/app/service/service_worker/csp_rule.test.ts b/src/app/service/service_worker/csp_rule.test.ts new file mode 100644 index 000000000..3e2055864 --- /dev/null +++ b/src/app/service/service_worker/csp_rule.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi, type Mocked } from "vitest"; +import type { Group } from "@Packages/message/server"; +import type { IMessageQueue } from "@Packages/message/message_queue"; +import { DEFAULT_CSP_RULE_STATE, type CspRuleState, type CspRuleStateDAO } from "@App/app/repo/csp_rule"; +import { CspRuleService, type CspRuleApplier } from "./csp_rule"; +import { compileCspRules } from "./csp_rule_compiler"; + +type Handler = (params?: unknown) => Promise; + +function createHarness(initialState?: CspRuleState) { + const handlers = new Map(); + const group = { + on: vi.fn((name: string, handler: Handler) => handlers.set(name, handler)), + } as unknown as Group; + const queue = { + publish: vi.fn(), + } as unknown as IMessageQueue; + const dao = { + state: initialState, + getState: vi.fn(async () => dao.state), + saveState: vi.fn(async (state: CspRuleState) => { + dao.state = state; + return state; + }), + } as unknown as CspRuleStateDAO & { state: CspRuleState | undefined }; + const applier = { + apply: vi.fn(async () => {}), + } as Mocked; + const service = new CspRuleService(group, queue, dao, compileCspRules, applier); + service.init(); + return { service, handlers, queue, dao, applier }; +} + +describe("CspRuleService", () => { + beforeEach(() => vi.restoreAllMocks()); + + it("初始化时按持久化 state 重建规则", async () => { + const state: CspRuleState = { + ...DEFAULT_CSP_RULE_STATE, + revision: 4, + rules: [ + { + id: "one", + name: "example.com", + enabled: true, + target: { type: "domains", domains: ["example.com"] }, + action: { type: "removeCspHeaders" }, + createdAt: 1, + updatedAt: 1, + }, + ], + }; + const { handlers, applier } = createHarness(state); + const snapshot = await handlers.get("getState")!(); + expect(snapshot).toMatchObject({ state, apply: { state: "applied", revision: 4 } }); + expect(applier.apply).toHaveBeenCalledWith(compileCspRules(state)); + }); + + it("创建规则会持久化、应用并发布新的 snapshot", async () => { + const { handlers, queue, dao } = createHarness(); + const initial = (await handlers.get("getState")!()) as { state: CspRuleState }; + const result = (await handlers.get("createRule")!({ + baseRevision: initial.state.revision, + name: "Example", + enabled: true, + target: { type: "domains", domains: ["https://Example.com/path"] }, + })) as { outcome: string; state: CspRuleState }; + expect(result.outcome).toBe("applied"); + expect(result.state.revision).toBe(1); + expect(result.state.rules[0].target).toEqual({ type: "domains", domains: ["example.com"] }); + expect(dao.saveState).toHaveBeenCalledOnce(); + expect(queue.publish).toHaveBeenCalledWith( + "cspRule/stateChanged", + expect.objectContaining({ state: result.state }) + ); + }); + + it("baseRevision 过期时返回 revision conflict 且不覆盖其他页面的修改", async () => { + const { handlers, dao, applier } = createHarness(); + const initial = (await handlers.get("getState")!()) as { state: CspRuleState }; + await handlers.get("createRule")!({ + baseRevision: initial.state.revision, + enabled: true, + target: { type: "domains", domains: ["example.com"] }, + }); + const before = dao.state; + await expect( + handlers.get("deleteRule")!({ baseRevision: initial.state.revision, id: before!.rules[0].id }) + ).rejects.toMatchObject({ code: "revision_conflict" }); + expect(dao.state).toBe(before); + expect(applier.apply).toHaveBeenCalledTimes(2); + }); + + it("DNR 更新失败时保存 state 并返回可重试错误,retryApply 不增加 revision", async () => { + const { handlers, applier } = createHarness(); + const initial = (await handlers.get("getState")!()) as { state: CspRuleState }; + applier.apply.mockRejectedValueOnce(new Error("permission denied")); + const failed = (await handlers.get("createRule")!({ + baseRevision: initial.state.revision, + enabled: true, + target: { type: "domains", domains: ["example.com"] }, + })) as { outcome: string; state: CspRuleState; apply: { state: string; desiredRevision: number } }; + expect(failed.outcome).toBe("apply-error"); + expect(failed.apply).toMatchObject({ state: "error", desiredRevision: 1, lastAppliedRevision: 0 }); + applier.apply.mockResolvedValueOnce(); + const retried = (await handlers.get("retryApply")!()) as { outcome: string; state: CspRuleState }; + expect(retried.outcome).toBe("applied"); + expect(retried.state.revision).toBe(1); + }); + + it("未知 schema 保留数据并返回 unsupported_schema", async () => { + const { handlers, applier } = createHarness({ schemaVersion: 2 } as unknown as CspRuleState); + await expect(handlers.get("getState")!()).rejects.toMatchObject({ code: "unsupported_schema" }); + expect(applier.apply).toHaveBeenCalledWith([]); + }); + + it("并发 mutation 按保存与 reconcile 的完整顺序串行执行", async () => { + const order: string[] = []; + const { handlers, applier } = createHarness(); + applier.apply.mockImplementation(async () => { + order.push("apply-start"); + await Promise.resolve(); + order.push("apply-end"); + }); + const first = handlers.get("getState")!(); + const initial = (await first) as { state: CspRuleState }; + const firstMutation = handlers.get("createRule")!({ + baseRevision: initial.state.revision, + enabled: true, + target: { type: "domains", domains: ["one.example"] }, + }); + const secondMutation = handlers.get("createRule")!({ + baseRevision: initial.state.revision, + enabled: true, + target: { type: "domains", domains: ["two.example"] }, + }); + await expect(firstMutation).resolves.toMatchObject({ outcome: "applied" }); + await expect(secondMutation).rejects.toMatchObject({ code: "revision_conflict" }); + expect(order).toEqual(["apply-start", "apply-end", "apply-start", "apply-end"]); + }); + + it("写后重新读取不一致时返回 storage error 且不调用 DNR", async () => { + const { handlers, dao, applier } = createHarness(); + const initial = (await handlers.get("getState")!()) as { state: CspRuleState }; + vi.mocked(dao.saveState).mockResolvedValueOnce({ ...initial.state, revision: initial.state.revision + 99 }); + + await expect( + handlers.get("createRule")!({ + baseRevision: initial.state.revision, + enabled: true, + target: { type: "domains", domains: ["example.com"] }, + }) + ).rejects.toMatchObject({ code: "storage_write_failed" }); + expect(applier.apply).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/service/service_worker/csp_rule.ts b/src/app/service/service_worker/csp_rule.ts new file mode 100644 index 000000000..398a63f75 --- /dev/null +++ b/src/app/service/service_worker/csp_rule.ts @@ -0,0 +1,367 @@ +import type { Group } from "@Packages/message/server"; +import type { IMessageQueue } from "@Packages/message/message_queue"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { + CspRuleStorageError, + CspRuleValidationError, + DEFAULT_CSP_RULE_STATE, + type CspRule, + type CspRuleState, + type CspRuleTarget, + validateCspRuleState, +} from "@App/app/repo/csp_rule"; +import type { CspRuleStateDAO } from "@App/app/repo/csp_rule"; +import { CspDomainError, normalizeCspDomain } from "@App/pkg/utils/csp_domain"; +import { type CspRuleApplier, compileCspRules } from "./csp_rule_compiler"; + +export type { CspRuleApplier } from "./csp_rule_compiler"; + +export type CspRuleTargetInput = CspRuleTarget; +export type CspRuleCreateInput = { + baseRevision: number; + name?: string; + enabled: boolean; + target: CspRuleTargetInput; +}; +export type CspRuleUpdateInput = { + baseRevision: number; + id: string; + patch: Partial>; +}; +export type CspRuleEnabledInput = { baseRevision: number; id: string; enabled: boolean }; +export type CspRuleDeleteInput = { baseRevision: number; id: string }; +export type CspRuleMasterEnabledInput = { baseRevision: number; enabled: boolean }; + +export type CspApplyStatus = + | { state: "applied"; revision: number; appliedAt: number } + | { + state: "error"; + code: "dnr_apply_failed"; + desiredRevision: number; + lastAppliedRevision?: number; + message: string; + }; + +export type CspRuleSnapshot = { state: CspRuleState; apply: CspApplyStatus }; +export type CspMutationResult = CspRuleSnapshot & { outcome: "applied" | "apply-error" }; + +export type CspRuleServiceErrorCode = + | "invalid_input" + | "not_found" + | "revision_conflict" + | "storage_write_failed" + | "unsupported_schema"; + +export type CspRuleServiceError = { + code: CspRuleServiceErrorCode; + path?: string; + messageKey?: string; + snapshot?: CspRuleSnapshot; +}; + +function serviceError( + code: CspRuleServiceErrorCode, + details: Omit = {} +): CspRuleServiceError { + return { code, ...details }; +} + +function cloneTarget(target: CspRuleTarget): CspRuleTarget { + return target.type === "allSites" ? { type: "allSites" } : { type: "domains", domains: [...target.domains] }; +} + +function sameTarget(left: CspRuleTarget, right: CspRuleTarget): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function defaultRuleName(target: CspRuleTarget): string { + if (target.type === "allSites") return "All websites"; + return `${target.domains[0]}${target.domains.length > 1 ? ` + ${target.domains.length - 1}` : ""}`; +} + +function normalizeRuleName(name: unknown, target: CspRuleTarget, path: string): string { + if (name === undefined || (typeof name === "string" && name.trim() === "")) return defaultRuleName(target); + if (typeof name !== "string" || Array.from(name.trim()).length > 80) { + throw serviceError("invalid_input", { path, messageKey: "rule_name_invalid" }); + } + return name.trim(); +} + +function normalizeTarget(target: unknown, path: string): CspRuleTarget { + if (!target || typeof target !== "object" || !("type" in target)) { + throw serviceError("invalid_input", { path, messageKey: "target_invalid" }); + } + const input = target as { type?: unknown; domains?: unknown }; + if (input.type === "allSites") return { type: "allSites" }; + if (input.type !== "domains" || !Array.isArray(input.domains)) { + throw serviceError("invalid_input", { path, messageKey: "target_invalid" }); + } + if (input.domains.length === 0) { + throw serviceError("invalid_input", { path: `${path}.domains`, messageKey: "domain_required" }); + } + const domains: string[] = []; + for (const [index, domain] of input.domains.entries()) { + try { + if (typeof domain !== "string") throw new CspDomainError("domain_invalid"); + const normalized = normalizeCspDomain(domain); + if (!domains.includes(normalized)) domains.push(normalized); + } catch (error) { + const messageKey = error instanceof CspDomainError ? error.messageKey : "domain_invalid"; + throw serviceError("invalid_input", { path: `${path}.domains[${index}]`, messageKey }); + } + } + return { type: "domains", domains }; +} + +function validateBaseRevision(value: unknown): asserts value is number { + if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { + throw serviceError("invalid_input", { path: "baseRevision", messageKey: "revision_invalid" }); + } +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return "DNR update failed"; +} + +export class CspRuleService { + private confirmedState: CspRuleState | undefined; + private applyStatus: CspApplyStatus | undefined; + private ready: Promise = Promise.resolve(); + private mutationQueue: Promise = Promise.resolve(); + + constructor( + private readonly group: Group, + private readonly messageQueue: IMessageQueue, + private readonly stateDAO: CspRuleStateDAO, + private readonly compiler: typeof compileCspRules = compileCspRules, + private readonly applier: CspRuleApplier + ) {} + + init() { + this.group.on("getState", () => this.getState()); + this.group.on("createRule", (input: CspRuleCreateInput) => this.createRule(input)); + this.group.on("updateRule", (input: CspRuleUpdateInput) => this.updateRule(input)); + this.group.on("deleteRule", (input: CspRuleDeleteInput) => this.deleteRule(input)); + this.group.on("setRuleEnabled", (input: CspRuleEnabledInput) => this.setRuleEnabled(input)); + this.group.on("setMasterEnabled", (input: CspRuleMasterEnabledInput) => this.setMasterEnabled(input)); + this.group.on("retryApply", () => this.retryApply()); + this.ready = this.initialize(); + } + + private async initialize(): Promise { + let state: CspRuleState; + try { + state = (await this.stateDAO.getState()) ?? { ...DEFAULT_CSP_RULE_STATE, rules: [] }; + validateCspRuleState(state); + } catch (error) { + try { + await this.applier.apply([]); + } catch { + // 规则损坏时仍须保留原数据,清理失败由后续恢复重试处理。 + } + if (error instanceof CspRuleValidationError) { + throw serviceError("unsupported_schema", { path: error.path, messageKey: error.messageKey }); + } + throw serviceError("storage_write_failed"); + } + this.confirmedState = state; + await this.reconcile(state); + } + + private async waitUntilReady(): Promise { + await this.ready; + } + + private snapshot(): CspRuleSnapshot { + if (!this.confirmedState || !this.applyStatus) throw serviceError("storage_write_failed"); + return { state: this.confirmedState, apply: this.applyStatus }; + } + + private async enqueue(operation: () => Promise): Promise { + const next = this.mutationQueue.then(operation); + this.mutationQueue = next.then( + () => undefined, + () => undefined + ); + return next; + } + + private async reconcile(state: CspRuleState): Promise { + try { + await this.applier.apply(this.compiler(state)); + const applied: CspApplyStatus = { state: "applied", revision: state.revision, appliedAt: Date.now() }; + this.applyStatus = applied; + return applied; + } catch (error) { + const previous = this.applyStatus; + const failed: CspApplyStatus = { + state: "error", + code: "dnr_apply_failed", + desiredRevision: state.revision, + lastAppliedRevision: previous?.state === "applied" ? previous.revision : previous?.lastAppliedRevision, + message: errorMessage(error), + }; + this.applyStatus = failed; + return failed; + } + } + + private async getState(): Promise { + await this.waitUntilReady(); + return this.snapshot(); + } + + private async currentForMutation(baseRevision: unknown): Promise { + await this.waitUntilReady(); + validateBaseRevision(baseRevision); + const current = this.confirmedState!; + if (baseRevision !== current.revision) { + throw serviceError("revision_conflict", { snapshot: this.snapshot() }); + } + return current; + } + + private async saveAndApply(state: CspRuleState): Promise { + let saved: CspRuleState; + try { + saved = await this.stateDAO.saveState(state); + validateCspRuleState(saved); + if (JSON.stringify(saved) !== JSON.stringify(state)) throw new CspRuleStorageError(); + } catch (error) { + if (error instanceof CspRuleValidationError) { + throw serviceError("storage_write_failed", { path: error.path, messageKey: error.messageKey }); + } + throw serviceError("storage_write_failed"); + } + this.confirmedState = saved; + const apply = await this.reconcile(saved); + const snapshot = this.snapshot(); + this.messageQueue.publish("cspRule/stateChanged", snapshot); + return { ...snapshot, outcome: apply.state === "applied" ? "applied" : "apply-error" }; + } + + private async createRule(input: CspRuleCreateInput): Promise { + return this.enqueue(async () => { + const current = await this.currentForMutation(input?.baseRevision); + if (typeof input?.enabled !== "boolean") { + throw serviceError("invalid_input", { path: "enabled", messageKey: "boolean_invalid" }); + } + const target = normalizeTarget(input.target, "target"); + const name = normalizeRuleName(input.name, target, "name"); + const now = Date.now(); + const rule: CspRule = { + id: uuidv4(), + name, + enabled: input.enabled, + target, + action: { type: "removeCspHeaders" }, + createdAt: now, + updatedAt: now, + }; + const state: CspRuleState = { ...current, revision: current.revision + 1, rules: [...current.rules, rule] }; + try { + validateCspRuleState(state); + } catch (error) { + if (error instanceof CspRuleValidationError) { + throw serviceError("invalid_input", { path: error.path, messageKey: error.messageKey }); + } + throw error; + } + return this.saveAndApply(state); + }); + } + + private async updateRule(input: CspRuleUpdateInput): Promise { + return this.enqueue(async () => { + const current = await this.currentForMutation(input?.baseRevision); + const index = current.rules.findIndex((rule) => rule.id === input?.id); + if (index < 0) throw serviceError("not_found", { path: "id", messageKey: "rule_not_found" }); + const patch = input.patch; + if (!patch || Object.keys(patch).length === 0) { + throw serviceError("invalid_input", { path: "patch", messageKey: "patch_empty" }); + } + for (const key of Object.keys(patch)) { + if (key !== "name" && key !== "target") { + throw serviceError("invalid_input", { path: `patch.${key}`, messageKey: "patch_field_invalid" }); + } + } + const oldRule = current.rules[index]; + const target = + patch.target === undefined ? cloneTarget(oldRule.target) : normalizeTarget(patch.target, "patch.target"); + const name = normalizeRuleName(patch.name === undefined ? oldRule.name : patch.name, target, "patch.name"); + if (name === oldRule.name && sameTarget(target, oldRule.target)) { + return { ...this.snapshot(), outcome: this.applyStatus?.state === "applied" ? "applied" : "apply-error" }; + } + const rules = [...current.rules]; + rules[index] = { ...oldRule, name, target, updatedAt: Date.now() }; + const state: CspRuleState = { ...current, revision: current.revision + 1, rules }; + try { + validateCspRuleState(state); + } catch (error) { + if (error instanceof CspRuleValidationError) { + throw serviceError("invalid_input", { path: error.path, messageKey: error.messageKey }); + } + throw error; + } + return this.saveAndApply(state); + }); + } + + private async deleteRule(input: CspRuleDeleteInput): Promise { + return this.enqueue(async () => { + const current = await this.currentForMutation(input?.baseRevision); + if (!current.rules.some((rule) => rule.id === input?.id)) { + throw serviceError("not_found", { path: "id", messageKey: "rule_not_found" }); + } + const state: CspRuleState = { + ...current, + revision: current.revision + 1, + rules: current.rules.filter((rule) => rule.id !== input.id), + }; + return this.saveAndApply(state); + }); + } + + private async setRuleEnabled(input: CspRuleEnabledInput): Promise { + return this.enqueue(async () => { + const current = await this.currentForMutation(input?.baseRevision); + const index = current.rules.findIndex((rule) => rule.id === input?.id); + if (index < 0) throw serviceError("not_found", { path: "id", messageKey: "rule_not_found" }); + if (typeof input.enabled !== "boolean") { + throw serviceError("invalid_input", { path: "enabled", messageKey: "boolean_invalid" }); + } + if (current.rules[index].enabled === input.enabled) { + return { ...this.snapshot(), outcome: this.applyStatus?.state === "applied" ? "applied" : "apply-error" }; + } + const rules = [...current.rules]; + rules[index] = { ...rules[index], enabled: input.enabled, updatedAt: Date.now() }; + return this.saveAndApply({ ...current, revision: current.revision + 1, rules }); + }); + } + + private async setMasterEnabled(input: CspRuleMasterEnabledInput): Promise { + return this.enqueue(async () => { + const current = await this.currentForMutation(input?.baseRevision); + if (typeof input.enabled !== "boolean") { + throw serviceError("invalid_input", { path: "enabled", messageKey: "boolean_invalid" }); + } + if (current.masterEnabled === input.enabled) { + return { ...this.snapshot(), outcome: this.applyStatus?.state === "applied" ? "applied" : "apply-error" }; + } + return this.saveAndApply({ ...current, masterEnabled: input.enabled, revision: current.revision + 1 }); + }); + } + + private async retryApply(): Promise { + return this.enqueue(async () => { + await this.waitUntilReady(); + const state = this.confirmedState!; + const apply = await this.reconcile(state); + const snapshot = this.snapshot(); + this.messageQueue.publish("cspRule/stateChanged", snapshot); + return { ...snapshot, outcome: apply.state === "applied" ? "applied" : "apply-error" }; + }); + } +} diff --git a/src/app/service/service_worker/csp_rule_compiler.test.ts b/src/app/service/service_worker/csp_rule_compiler.test.ts new file mode 100644 index 000000000..415fb2801 --- /dev/null +++ b/src/app/service/service_worker/csp_rule_compiler.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { compileCspRules, CSP_RULE_ID, DeclarativeNetRequestCspApplier } from "./csp_rule_compiler"; +import type { CspRuleState } from "@App/app/repo/csp_rule"; + +const baseState: CspRuleState = { + schemaVersion: 1, + revision: 1, + masterEnabled: true, + rules: [], +}; + +const rule = (id: string, target: CspRuleState["rules"][number]["target"], enabled = true) => ({ + id, + name: id, + enabled, + target, + action: { type: "removeCspHeaders" as const }, + createdAt: 1, + updatedAt: 1, +}); + +describe("CSP DNR 编译", () => { + it("多个规则的域名合并为一条 DNR 规则并按 ASCII 排序", () => { + expect( + compileCspRules({ + ...baseState, + rules: [ + rule("one", { type: "domains", domains: ["z.example.com", "example.com"] }), + rule("two", { type: "domains", domains: ["example.com", "a.example.com"] }), + ], + }) + ).toEqual([ + expect.objectContaining({ + id: CSP_RULE_ID, + priority: 1, + action: { + type: "modifyHeaders", + responseHeaders: [ + { header: "content-security-policy", operation: "remove" }, + { header: "content-security-policy-report-only", operation: "remove" }, + { header: "x-content-security-policy", operation: "remove" }, + { header: "x-webkit-csp", operation: "remove" }, + ], + }, + condition: { + requestDomains: ["a.example.com", "example.com", "z.example.com"], + resourceTypes: ["main_frame", "sub_frame"], + }, + }), + ]); + }); + + it("存在所有网站规则时编译为 urlFilter 星号", () => { + expect( + compileCspRules({ + ...baseState, + rules: [rule("all", { type: "allSites" })], + })[0].condition + ).toEqual({ urlFilter: "*", resourceTypes: ["main_frame", "sub_frame"] }); + }); + + it("总开关关闭或没有启用规则时编译为空规则", () => { + expect(compileCspRules({ ...baseState, masterEnabled: false })).toEqual([]); + expect( + compileCspRules({ ...baseState, rules: [rule("off", { type: "domains", domains: ["example.com"] }, false)] }) + ).toEqual([]); + }); + + it("更新只拥有 ID 2001 且不会移除脚本安装 dynamic rule ID 2", async () => { + const dnr = chrome.declarativeNetRequest as typeof chrome.declarativeNetRequest & { + resetMock(): void; + dynamicUpdateError?: string; + getDynamicRules(): Promise; + }; + dnr.resetMock(); + await chrome.declarativeNetRequest.updateDynamicRules({ + addRules: [{ id: 2, priority: 1, action: { type: "allow" }, condition: {} }], + }); + const applier = new DeclarativeNetRequestCspApplier(); + await applier.apply( + compileCspRules({ ...baseState, rules: [rule("one", { type: "domains", domains: ["example.com"] })] }) + ); + expect((await dnr.getDynamicRules()).map((item) => item.id)).toEqual([2, CSP_RULE_ID]); + await applier.apply([]); + expect((await dnr.getDynamicRules()).map((item) => item.id)).toEqual([2]); + }); + + it("DNR 更新失败时保留现有规则", async () => { + const dnr = chrome.declarativeNetRequest as typeof chrome.declarativeNetRequest & { + dynamicUpdateError?: string; + getDynamicRules(): Promise; + }; + const before = await dnr.getDynamicRules(); + dnr.dynamicUpdateError = "permission denied"; + await expect(new DeclarativeNetRequestCspApplier().apply([])).rejects.toThrow("permission denied"); + expect(await dnr.getDynamicRules()).toEqual(before); + }); +}); diff --git a/src/app/service/service_worker/csp_rule_compiler.ts b/src/app/service/service_worker/csp_rule_compiler.ts new file mode 100644 index 000000000..e6d6b7bb3 --- /dev/null +++ b/src/app/service/service_worker/csp_rule_compiler.ts @@ -0,0 +1,93 @@ +import type { CspRuleState } from "@App/app/repo/csp_rule"; + +export const CSP_RULE_ID = 2001; + +const CSP_HEADERS: chrome.declarativeNetRequest.ModifyHeaderInfo[] = [ + { header: "content-security-policy", operation: "remove" }, + { header: "content-security-policy-report-only", operation: "remove" }, + { header: "x-content-security-policy", operation: "remove" }, + { header: "x-webkit-csp", operation: "remove" }, +]; + +const RESOURCE_TYPES = ["main_frame", "sub_frame"] as chrome.declarativeNetRequest.ResourceType[]; + +export function compileCspRules(state: CspRuleState): chrome.declarativeNetRequest.Rule[] { + if (!state.masterEnabled) return []; + const enabledRules = state.rules.filter((rule) => rule.enabled); + if (enabledRules.length === 0) return []; + + const hasAllSites = enabledRules.some((rule) => rule.target.type === "allSites"); + const condition: chrome.declarativeNetRequest.RuleCondition = hasAllSites + ? { urlFilter: "*", resourceTypes: RESOURCE_TYPES } + : { + requestDomains: [ + ...new Set( + enabledRules.flatMap((rule) => (rule.target.type === "domains" ? rule.target.domains : [])).sort() + ), + ], + resourceTypes: RESOURCE_TYPES, + }; + + return [ + { + id: CSP_RULE_ID, + priority: 1, + action: { type: "modifyHeaders", responseHeaders: CSP_HEADERS.map((header) => ({ ...header })) }, + condition, + }, + ]; +} + +export interface CspRuleApplier { + apply(rules: chrome.declarativeNetRequest.Rule[]): Promise; +} + +type DynamicRuleUpdateOptions = { + removeRuleIds: number[]; + addRules: chrome.declarativeNetRequest.Rule[]; +}; + +function toApplyError(error: unknown): Error { + if (error instanceof Error) return error; + if (typeof error === "string") return new Error(error); + return new Error("DNR update failed"); +} + +export class DeclarativeNetRequestCspApplier implements CspRuleApplier { + apply(rules: chrome.declarativeNetRequest.Rule[]): Promise { + const options = { removeRuleIds: [CSP_RULE_ID], addRules: rules }; + return new Promise((resolve, reject) => { + let settled = false; + const done = (error?: unknown) => { + if (settled) return; + settled = true; + if (error) reject(toApplyError(error)); + else resolve(); + }; + + try { + const updateDynamicRules = chrome.declarativeNetRequest.updateDynamicRules as unknown as ( + this: typeof chrome.declarativeNetRequest, + options: DynamicRuleUpdateOptions, + callback: () => void + ) => Promise | void; + const result = updateDynamicRules.call(chrome.declarativeNetRequest, options, () => { + const lastError = chrome.runtime.lastError; + if (lastError) { + done(lastError.message); + return; + } + done(); + }); + if (result && typeof result.then === "function") { + void result.then( + () => done(), + (error: unknown) => done(error) + ); + } + } catch (error) { + done(error); + } + }); + } +} diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index 799733c7d..395a2311f 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -25,6 +25,9 @@ import { InfoNotification, shouldAutoOpenChangelog } from "./utils"; import { AgentService } from "@App/app/service/agent/service_worker/agent"; import { extensionEnv, getExtensionUserAgentData } from "../extension/extension_env"; import { cleanupStaleTempStorageEntries } from "./temp"; +import { CspRuleStateDAO } from "@App/app/repo/csp_rule"; +import { CspRuleService } from "./csp_rule"; +import { DeclarativeNetRequestCspApplier, compileCspRules } from "./csp_rule_compiler"; // service worker的管理器 export default class ServiceWorkerManager { @@ -116,6 +119,16 @@ export default class ServiceWorkerManager { faviconDAO ); system.init(); + + const cspRule = new CspRuleService( + this.api.group("cspRule"), + this.mq, + new CspRuleStateDAO(), + compileCspRules, + new DeclarativeNetRequestCspApplier() + ); + cspRule.init(); + const agent = new AgentService(this.api.group("agent"), this.offscreenSend, resource); agent.init(); diff --git a/src/locales/de-DE/tools.json b/src/locales/de-DE/tools.json index 8568963be..a9e98e0b0 100644 --- a/src/locales/de-DE/tools.json +++ b/src/locales/de-DE/tools.json @@ -14,5 +14,70 @@ "local_backup": "Lokale Sicherung", "cloud_backup": "Cloud-Sicherung", "auto_backup": "Automatische Sicherung", - "data_migration": "Datenmigration" + "data_migration": "Datenmigration", + "csp_rules": "CSP-Regeln", + "csp_rules_description": "CSP-Antwortheader nur von von Ihnen ausgewählten Websites entfernen. Dadurch wird deren integrierter Schutz geschwächt.", + "csp_rules_scope": "Domainregeln gelten für alle Pfade und Subdomains und unabhängig davon, ob ein Skript läuft.", + "csp_rules_reload": "Betroffene Seiten nach dem Speichern, Aktivieren, Deaktivieren oder Pausieren neu laden.", + "csp_rules_risk": "Manche Websites funktionieren ohne CSP möglicherweise nicht. Bei Finanz-Websites besonders vorsichtig sein.", + "csp_rules_trusted_types": "Beim Entfernen von CSP werden auch Trusted-Types-Direktiven entfernt.", + "csp_run_rules": "CSP-Regeln ausführen", + "csp_summary_active": "{{count}} aktive Regeln · {{websites}} Websites", + "csp_summary_all_sites": "{{count}} aktive Regeln · Alle Websites", + "csp_summary_paused": "Pausiert · {{count}} Regeln behalten", + "csp_apply_error": "CSP-Regeln konnten nicht angewendet werden.", + "csp_apply_error_detail": "Die Regeln wurden gespeichert, aber der Browser verwendet möglicherweise weiterhin die vorherige Version.", + "csp_browser_error": "Browserfehler", + "csp_retry": "Erneut versuchen", + "csp_add_rule": "Regel hinzufügen", + "csp_no_rules": "Keine CSP-Regeln", + "csp_no_rules_description": "Nur Websites hinzufügen, auf denen ein Skript durch CSP blockiert wird.", + "csp_show_more": "Mehr anzeigen", + "csp_remove_csp": "CSP entfernen", + "csp_all_websites": "Alle Websites", + "csp_enabled": "Aktiviert", + "csp_disabled": "Deaktiviert", + "csp_more_actions": "Weitere Aktionen", + "csp_edit": "Bearbeiten", + "csp_delete": "Löschen", + "csp_delete_description": "Diese CSP-Regel löschen?", + "csp_add_title": "CSP-Regel hinzufügen", + "csp_edit_title": "CSP-Regel bearbeiten", + "csp_websites": "Websites", + "csp_websites_help": "Alle Pfade und Subdomains sind enthalten.", + "csp_rule_name": "Name", + "csp_optional": "Optional", + "csp_enabled_field": "Aktiviert", + "csp_advanced_scope": "Erweiterter Bereich", + "csp_all_sites_warning": "CSP-Antwortheader werden aus allen passenden Haupt- und Unterframe-Antworten entfernt.", + "csp_confirm_all_sites_title": "Alle Websites beeinflussen?", + "csp_confirm_all_sites_description": "Der integrierte Schutz jeder passenden Website wird geschwächt. Fortfahren?", + "csp_confirm": "Fortfahren", + "csp_cancel": "Abbrechen", + "csp_save_rule": "Regel speichern", + "csp_rule_saved": "Regel gespeichert. Betroffene Seiten neu laden, um sie anzuwenden.", + "csp_rule_saved_apply_failed": "Regel gespeichert, aber Browserregeln konnten nicht aktualisiert werden. Vor der Nutzung erneut versuchen.", + "csp_revision_conflict": "Diese Seite ist nicht aktuell. Vor dem Speichern die neuesten Regeln laden.", + "csp_storage_error": "Die Regel konnte nicht gespeichert werden. Ihre Eingaben bleiben erhalten.", + "csp_load_error": "CSP-Regeln konnten nicht geladen werden. Erneut versuchen.", + "csp_unsupported_schema": "Dieses Datenformat für CSP-Regeln wird noch nicht unterstützt.", + "csp_error_domain_required": "Mindestens eine Website eingeben.", + "csp_error_domain_invalid": "Einen gültigen HTTP(S)-Website-Hostnamen eingeben.", + "csp_error_domain_credentials": "Benutzername und Passwort aus der URL entfernen.", + "csp_error_domain_wildcard": "example.com eingeben; Subdomains werden automatisch eingeschlossen.", + "csp_error_domain_single_label": "Eine Domain mit Punkt eingeben, z. B. example.com.", + "csp_error_domain_too_long": "Diese Domain ist zu lang.", + "csp_error_domain_count_invalid": "Pro Regel 1 bis 100 Domains verwenden.", + "csp_error_unique_domain_count_invalid": "Das Limit von 1.000 Domains wurde erreicht.", + "csp_error_domain_duplicate": "Doppelte Domains entfernen.", + "csp_error_target_invalid": "Einen gültigen Website-Bereich auswählen.", + "csp_error_rule_name_invalid": "Der Name darf höchstens 80 Zeichen enthalten.", + "csp_error_boolean_invalid": "Aktiviert oder deaktiviert auswählen.", + "csp_error_patch_empty": "Mindestens ein Feld ändern.", + "csp_error_patch_field_invalid": "Dieses Feld kann hier nicht geändert werden.", + "csp_error_rule_not_found": "Diese Regel existiert nicht mehr.", + "csp_error_revision_invalid": "Die Regelversion ist ungültig.", + "csp_error_rule_count_invalid": "Das Limit von 100 Regeln wurde erreicht.", + "csp_error_unsupported_schema": "Dieses Datenformat für CSP-Regeln wird noch nicht unterstützt.", + "csp_duplicate_domain_notice": "Diese Domain wird bereits von einer anderen Regel verwendet. Das ist zulässig; beide Regeln werden angewendet." } diff --git a/src/locales/en-US/tools.json b/src/locales/en-US/tools.json index 1e8064ed5..5461e5fe4 100644 --- a/src/locales/en-US/tools.json +++ b/src/locales/en-US/tools.json @@ -14,5 +14,70 @@ "local_backup": "Local Backup", "cloud_backup": "Cloud Backup", "auto_backup": "Auto Backup", - "data_migration": "Data Migration" + "data_migration": "Data Migration", + "csp_rules": "CSP rules", + "csp_rules_description": "Remove CSP response headers only from websites you choose. This weakens those websites' built-in protection.", + "csp_rules_scope": "Domain rules cover all paths and subdomains. Rules are independent of whether a script runs.", + "csp_rules_reload": "Reload affected pages after saving, enabling, disabling, or pausing a rule.", + "csp_rules_risk": "Some websites may reject requests when CSP is missing. Be especially careful with financial websites.", + "csp_rules_trusted_types": "Removing CSP also removes Trusted Types directives.", + "csp_run_rules": "Run CSP rules", + "csp_summary_active": "{{count}} active rules · {{websites}} websites", + "csp_summary_all_sites": "{{count}} active rules · All websites", + "csp_summary_paused": "Paused · {{count}} rules kept", + "csp_apply_error": "CSP rules could not be applied.", + "csp_apply_error_detail": "The rules are saved, but the browser may still use the previous revision.", + "csp_browser_error": "Browser error", + "csp_retry": "Retry", + "csp_add_rule": "Add rule", + "csp_no_rules": "No CSP rules", + "csp_no_rules_description": "Add only the websites where a script is blocked by CSP.", + "csp_show_more": "Show more", + "csp_remove_csp": "Remove CSP", + "csp_all_websites": "All websites", + "csp_enabled": "Enabled", + "csp_disabled": "Disabled", + "csp_more_actions": "More actions", + "csp_edit": "Edit", + "csp_delete": "Delete", + "csp_delete_description": "Delete this CSP rule?", + "csp_add_title": "Add CSP rule", + "csp_edit_title": "Edit CSP rule", + "csp_websites": "Websites", + "csp_websites_help": "All paths and subdomains are included.", + "csp_rule_name": "Name", + "csp_optional": "Optional", + "csp_enabled_field": "Enabled", + "csp_advanced_scope": "Advanced scope", + "csp_all_sites_warning": "This removes CSP response headers from all matching main-frame and sub-frame responses.", + "csp_confirm_all_sites_title": "Affect all websites?", + "csp_confirm_all_sites_description": "This weakens built-in protection on every matching website. Continue?", + "csp_confirm": "Continue", + "csp_cancel": "Cancel", + "csp_save_rule": "Save rule", + "csp_rule_saved": "Rule saved. Reload affected pages to apply it.", + "csp_rule_saved_apply_failed": "Rule saved, but browser rules could not be updated. Retry before relying on it.", + "csp_revision_conflict": "This page is out of date. Reload the latest rules before saving.", + "csp_storage_error": "The rule could not be saved. Your form entries were kept.", + "csp_load_error": "CSP rules could not be loaded. Try again.", + "csp_unsupported_schema": "This CSP rules data format is not supported yet.", + "csp_error_domain_required": "Enter at least one website.", + "csp_error_domain_invalid": "Enter a valid HTTP(S) website hostname.", + "csp_error_domain_credentials": "Remove the username and password from the URL.", + "csp_error_domain_wildcard": "Enter example.com instead; subdomains are included automatically.", + "csp_error_domain_single_label": "Enter a domain with a dot, such as example.com.", + "csp_error_domain_too_long": "This domain is too long.", + "csp_error_domain_count_invalid": "Use between 1 and 100 domains per rule.", + "csp_error_unique_domain_count_invalid": "You have reached the 1,000-domain limit.", + "csp_error_domain_duplicate": "Remove duplicate domains.", + "csp_error_target_invalid": "Choose a valid website scope.", + "csp_error_rule_name_invalid": "Use a name with 80 characters or fewer.", + "csp_error_boolean_invalid": "Choose enabled or disabled.", + "csp_error_patch_empty": "Change at least one field.", + "csp_error_patch_field_invalid": "This field cannot be changed here.", + "csp_error_rule_not_found": "This rule no longer exists.", + "csp_error_revision_invalid": "The rule revision is invalid.", + "csp_error_rule_count_invalid": "You have reached the 100-rule limit.", + "csp_error_unsupported_schema": "This CSP rules data format is not supported yet.", + "csp_duplicate_domain_notice": "This domain is already used by another rule. It is allowed, but both rules will apply." } diff --git a/src/locales/ja-JP/tools.json b/src/locales/ja-JP/tools.json index 778a8a710..148c9cd12 100644 --- a/src/locales/ja-JP/tools.json +++ b/src/locales/ja-JP/tools.json @@ -14,5 +14,70 @@ "local_backup": "ローカルバックアップ", "cloud_backup": "クラウドバックアップ", "auto_backup": "自動バックアップ", - "data_migration": "データ移行" + "data_migration": "データ移行", + "csp_rules": "CSP ルール", + "csp_rules_description": "選択したウェブサイトだけから CSP レスポンスヘッダーを削除します。サイト本来の安全保護が弱くなります。", + "csp_rules_scope": "ドメインルールはすべてのパスとサブドメインに適用され、スクリプトの有無には依存しません。", + "csp_rules_reload": "ルールの保存、オン・オフ、停止後は対象ページを再読み込みしてください。", + "csp_rules_risk": "CSP がないと動作しないサイトがあります。金融サイトでは特に注意してください。", + "csp_rules_trusted_types": "CSP を削除すると Trusted Types のディレクティブも削除されます。", + "csp_run_rules": "CSP ルールを実行", + "csp_summary_active": "有効なルール {{count}} 件 · ウェブサイト {{websites}} 件", + "csp_summary_all_sites": "有効なルール {{count}} 件 · すべてのウェブサイト", + "csp_summary_paused": "停止中 · 保持中のルール {{count}} 件", + "csp_apply_error": "CSP ルールを適用できませんでした。", + "csp_apply_error_detail": "ルールは保存されましたが、ブラウザーは前のリビジョンを使っている可能性があります。", + "csp_browser_error": "ブラウザーエラー", + "csp_retry": "再試行", + "csp_add_rule": "ルールを追加", + "csp_no_rules": "CSP ルールはありません", + "csp_no_rules_description": "CSP によってスクリプトがブロックされるウェブサイトだけを追加してください。", + "csp_show_more": "さらに表示", + "csp_remove_csp": "CSP を削除", + "csp_all_websites": "すべてのウェブサイト", + "csp_enabled": "有効", + "csp_disabled": "無効", + "csp_more_actions": "その他の操作", + "csp_edit": "編集", + "csp_delete": "削除", + "csp_delete_description": "この CSP ルールを削除しますか?", + "csp_add_title": "CSP ルールを追加", + "csp_edit_title": "CSP ルールを編集", + "csp_websites": "ウェブサイト", + "csp_websites_help": "すべてのパスとサブドメインが含まれます。", + "csp_rule_name": "名前", + "csp_optional": "任意", + "csp_enabled_field": "有効", + "csp_advanced_scope": "詳細な範囲", + "csp_all_sites_warning": "一致するすべてのメインフレームとサブフレームのレスポンスから CSP ヘッダーを削除します。", + "csp_confirm_all_sites_title": "すべてのウェブサイトに適用しますか?", + "csp_confirm_all_sites_description": "一致するすべてのウェブサイトの保護を弱めます。続行しますか?", + "csp_confirm": "続行", + "csp_cancel": "キャンセル", + "csp_save_rule": "ルールを保存", + "csp_rule_saved": "ルールを保存しました。適用するには対象ページを再読み込みしてください。", + "csp_rule_saved_apply_failed": "ルールは保存されましたが、ブラウザーのルールを更新できませんでした。信頼する前に再試行してください。", + "csp_revision_conflict": "このページは最新ではありません。最新のルールを読み込んでから保存してください。", + "csp_storage_error": "ルールを保存できませんでした。入力内容は保持されています。", + "csp_load_error": "CSP ルールを読み込めませんでした。再試行してください。", + "csp_unsupported_schema": "この CSP ルールデータ形式はまだサポートされていません。", + "csp_error_domain_required": "ウェブサイトを 1 つ以上入力してください。", + "csp_error_domain_invalid": "有効な HTTP(S) ウェブサイトのホスト名を入力してください。", + "csp_error_domain_credentials": "URL からユーザー名とパスワードを削除してください。", + "csp_error_domain_wildcard": "example.com と入力してください。サブドメインは自動的に含まれます。", + "csp_error_domain_single_label": "example.com のようにドットを含むドメインを入力してください。", + "csp_error_domain_too_long": "このドメインは長すぎます。", + "csp_error_domain_count_invalid": "1 つのルールにつき 1~100 個のドメインを使用してください。", + "csp_error_unique_domain_count_invalid": "1,000 ドメインの上限に達しました。", + "csp_error_domain_duplicate": "重複するドメインを削除してください。", + "csp_error_target_invalid": "有効なウェブサイト範囲を選択してください。", + "csp_error_rule_name_invalid": "名前は 80 文字以内にしてください。", + "csp_error_boolean_invalid": "有効または無効を選択してください。", + "csp_error_patch_empty": "少なくとも 1 つの項目を変更してください。", + "csp_error_patch_field_invalid": "この項目はここでは変更できません。", + "csp_error_rule_not_found": "このルールはすでに存在しません。", + "csp_error_revision_invalid": "ルールのリビジョンが無効です。", + "csp_error_rule_count_invalid": "100 ルールの上限に達しました。", + "csp_error_unsupported_schema": "この CSP ルールデータ形式はまだサポートされていません。", + "csp_duplicate_domain_notice": "このドメインは別のルールでも使用されています。保存できますが、両方のルールが適用されます。" } diff --git a/src/locales/ru-RU/tools.json b/src/locales/ru-RU/tools.json index 673d4ec2f..9c945f6bf 100644 --- a/src/locales/ru-RU/tools.json +++ b/src/locales/ru-RU/tools.json @@ -14,5 +14,70 @@ "local_backup": "Локальная резервная копия", "cloud_backup": "Облачная резервная копия", "auto_backup": "Автоматическая резервная копия", - "data_migration": "Миграция данных" + "data_migration": "Миграция данных", + "csp_rules": "Правила CSP", + "csp_rules_description": "Удаляйте заголовки ответа CSP только на выбранных вами сайтах. Это ослабляет встроенную защиту этих сайтов.", + "csp_rules_scope": "Правила домена охватывают все пути и поддомены и не зависят от наличия скрипта.", + "csp_rules_reload": "После сохранения, включения, отключения или приостановки правила перезагрузите затронутые страницы.", + "csp_rules_risk": "Некоторые сайты могут не работать без CSP. Будьте особенно осторожны с финансовыми сайтами.", + "csp_rules_trusted_types": "Удаление CSP также удаляет директивы Trusted Types.", + "csp_run_rules": "Запускать правила CSP", + "csp_summary_active": "Активных правил: {{count}} · Сайтов: {{websites}}", + "csp_summary_all_sites": "Активных правил: {{count}} · Все сайты", + "csp_summary_paused": "Приостановлено · Сохранено правил: {{count}}", + "csp_apply_error": "Не удалось применить правила CSP.", + "csp_apply_error_detail": "Правила сохранены, но браузер может использовать предыдущую версию.", + "csp_browser_error": "Ошибка браузера", + "csp_retry": "Повторить", + "csp_add_rule": "Добавить правило", + "csp_no_rules": "Нет правил CSP", + "csp_no_rules_description": "Добавляйте только сайты, где CSP блокирует скрипт.", + "csp_show_more": "Показать ещё", + "csp_remove_csp": "Удалить CSP", + "csp_all_websites": "Все сайты", + "csp_enabled": "Включено", + "csp_disabled": "Выключено", + "csp_more_actions": "Другие действия", + "csp_edit": "Изменить", + "csp_delete": "Удалить", + "csp_delete_description": "Удалить это правило CSP?", + "csp_add_title": "Добавить правило CSP", + "csp_edit_title": "Изменить правило CSP", + "csp_websites": "Сайты", + "csp_websites_help": "Включены все пути и поддомены.", + "csp_rule_name": "Название", + "csp_optional": "Необязательно", + "csp_enabled_field": "Включено", + "csp_advanced_scope": "Расширенная область", + "csp_all_sites_warning": "Заголовки ответа CSP будут удалены из всех подходящих ответов основного и вложенного фрейма.", + "csp_confirm_all_sites_title": "Затронуть все сайты?", + "csp_confirm_all_sites_description": "Это ослабит встроенную защиту каждого подходящего сайта. Продолжить?", + "csp_confirm": "Продолжить", + "csp_cancel": "Отмена", + "csp_save_rule": "Сохранить правило", + "csp_rule_saved": "Правило сохранено. Перезагрузите затронутые страницы для применения.", + "csp_rule_saved_apply_failed": "Правило сохранено, но правила браузера не обновились. Повторите попытку перед использованием.", + "csp_revision_conflict": "Эта страница устарела. Загрузите последние правила перед сохранением.", + "csp_storage_error": "Не удалось сохранить правило. Введённые данные сохранены в форме.", + "csp_load_error": "Не удалось загрузить правила CSP. Повторите попытку.", + "csp_unsupported_schema": "Этот формат данных правил CSP пока не поддерживается.", + "csp_error_domain_required": "Введите хотя бы один сайт.", + "csp_error_domain_invalid": "Введите корректное имя хоста сайта HTTP(S).", + "csp_error_domain_credentials": "Удалите имя пользователя и пароль из URL.", + "csp_error_domain_wildcard": "Введите example.com; поддомены будут включены автоматически.", + "csp_error_domain_single_label": "Введите домен с точкой, например example.com.", + "csp_error_domain_too_long": "Этот домен слишком длинный.", + "csp_error_domain_count_invalid": "Используйте от 1 до 100 доменов в одном правиле.", + "csp_error_unique_domain_count_invalid": "Достигнут лимит в 1 000 доменов.", + "csp_error_domain_duplicate": "Удалите повторяющиеся домены.", + "csp_error_target_invalid": "Выберите корректную область сайтов.", + "csp_error_rule_name_invalid": "Название должно содержать не более 80 символов.", + "csp_error_boolean_invalid": "Выберите включённое или выключенное состояние.", + "csp_error_patch_empty": "Измените хотя бы одно поле.", + "csp_error_patch_field_invalid": "Это поле нельзя изменить здесь.", + "csp_error_rule_not_found": "Это правило больше не существует.", + "csp_error_revision_invalid": "Версия правила недействительна.", + "csp_error_rule_count_invalid": "Достигнут лимит в 100 правил.", + "csp_error_unsupported_schema": "Этот формат данных правил CSP пока не поддерживается.", + "csp_duplicate_domain_notice": "Этот домен уже используется другим правилом. Сохранение разрешено, применятся оба правила." } diff --git a/src/locales/tr-TR/tools.json b/src/locales/tr-TR/tools.json index 33f1be76c..a0a2ef347 100644 --- a/src/locales/tr-TR/tools.json +++ b/src/locales/tr-TR/tools.json @@ -14,5 +14,70 @@ "local_backup": "Yerel Yedekleme", "cloud_backup": "Bulut Yedeklemesi", "auto_backup": "Otomatik Yedekleme", - "data_migration": "Veri Geçişi" + "data_migration": "Veri Geçişi", + "csp_rules": "CSP kuralları", + "csp_rules_description": "CSP yanıt üst bilgilerini yalnızca seçtiğiniz web sitelerinden kaldırın. Bu, sitelerin yerleşik korumasını zayıflatır.", + "csp_rules_scope": "Alan adı kuralları tüm yolları ve alt alan adlarını kapsar; betiğin çalışıp çalışmamasından bağımsızdır.", + "csp_rules_reload": "Kuralı kaydettikten, etkinleştirdikten, devre dışı bıraktıktan veya duraklattıktan sonra etkilenen sayfaları yeniden yükleyin.", + "csp_rules_risk": "Bazı web siteleri CSP olmadan çalışmayabilir. Finans sitelerinde özellikle dikkatli olun.", + "csp_rules_trusted_types": "CSP'yi kaldırmak Trusted Types yönergelerini de kaldırır.", + "csp_run_rules": "CSP kurallarını çalıştır", + "csp_summary_active": "{{count}} etkin kural · {{websites}} web sitesi", + "csp_summary_all_sites": "{{count}} etkin kural · Tüm web siteleri", + "csp_summary_paused": "Duraklatıldı · {{count}} kural korunuyor", + "csp_apply_error": "CSP kuralları uygulanamadı.", + "csp_apply_error_detail": "Kurallar kaydedildi ancak tarayıcı önceki sürümü kullanıyor olabilir.", + "csp_browser_error": "Tarayıcı hatası", + "csp_retry": "Yeniden Dene", + "csp_add_rule": "Kural ekle", + "csp_no_rules": "CSP kuralı yok", + "csp_no_rules_description": "Yalnızca CSP tarafından betiğin engellendiği web sitelerini ekleyin.", + "csp_show_more": "Daha fazla göster", + "csp_remove_csp": "CSP'yi kaldır", + "csp_all_websites": "Tüm web siteleri", + "csp_enabled": "Etkin", + "csp_disabled": "Devre dışı", + "csp_more_actions": "Diğer işlemler", + "csp_edit": "Düzenle", + "csp_delete": "Sil", + "csp_delete_description": "Bu CSP kuralı silinsin mi?", + "csp_add_title": "CSP kuralı ekle", + "csp_edit_title": "CSP kuralını düzenle", + "csp_websites": "Web siteleri", + "csp_websites_help": "Tüm yollar ve alt alan adları dahildir.", + "csp_rule_name": "Ad", + "csp_optional": "İsteğe bağlı", + "csp_enabled_field": "Etkin", + "csp_advanced_scope": "Gelişmiş kapsam", + "csp_all_sites_warning": "CSP yanıt üst bilgileri eşleşen tüm ana çerçeve ve alt çerçeve yanıtlarından kaldırılır.", + "csp_confirm_all_sites_title": "Tüm web siteleri etkilensin mi?", + "csp_confirm_all_sites_description": "Bu, eşleşen her web sitesinin yerleşik korumasını zayıflatır. Devam edilsin mi?", + "csp_confirm": "Devam", + "csp_cancel": "İptal", + "csp_save_rule": "Kuralı kaydet", + "csp_rule_saved": "Kural kaydedildi. Uygulamak için etkilenen sayfaları yeniden yükleyin.", + "csp_rule_saved_apply_failed": "Kural kaydedildi ancak tarayıcı kuralları güncellenemedi. Güvenmeden önce yeniden deneyin.", + "csp_revision_conflict": "Bu sayfa güncel değil. Kaydetmeden önce en yeni kuralları yükleyin.", + "csp_storage_error": "Kural kaydedilemedi. Formdaki girişler korundu.", + "csp_load_error": "CSP kuralları yüklenemedi. Yeniden deneyin.", + "csp_unsupported_schema": "Bu CSP kuralı veri biçimi henüz desteklenmiyor.", + "csp_error_domain_required": "En az bir web sitesi girin.", + "csp_error_domain_invalid": "Geçerli bir HTTP(S) web sitesi ana bilgisayar adı girin.", + "csp_error_domain_credentials": "Kullanıcı adını ve parolayı URL'den kaldırın.", + "csp_error_domain_wildcard": "example.com girin; alt alan adları otomatik olarak dahil edilir.", + "csp_error_domain_single_label": "example.com gibi nokta içeren bir alan adı girin.", + "csp_error_domain_too_long": "Bu alan adı çok uzun.", + "csp_error_domain_count_invalid": "Her kural için 1 ile 100 alan adı kullanın.", + "csp_error_unique_domain_count_invalid": "1.000 alan adı sınırına ulaşıldı.", + "csp_error_domain_duplicate": "Yinelenen alan adlarını kaldırın.", + "csp_error_target_invalid": "Geçerli bir web sitesi kapsamı seçin.", + "csp_error_rule_name_invalid": "Ad en fazla 80 karakter içermelidir.", + "csp_error_boolean_invalid": "Etkin veya devre dışı seçin.", + "csp_error_patch_empty": "En az bir alanı değiştirin.", + "csp_error_patch_field_invalid": "Bu alan burada değiştirilemez.", + "csp_error_rule_not_found": "Bu kural artık yok.", + "csp_error_revision_invalid": "Kural sürümü geçersiz.", + "csp_error_rule_count_invalid": "100 kural sınırına ulaşıldı.", + "csp_error_unsupported_schema": "Bu CSP kuralı veri biçimi henüz desteklenmiyor.", + "csp_duplicate_domain_notice": "Bu alan adı başka bir kuralda zaten kullanılıyor. Kaydedilebilir; iki kural da uygulanır." } diff --git a/src/locales/vi-VN/tools.json b/src/locales/vi-VN/tools.json index 131097b71..3e8de56a1 100644 --- a/src/locales/vi-VN/tools.json +++ b/src/locales/vi-VN/tools.json @@ -14,5 +14,70 @@ "local_backup": "Sao lưu cục bộ", "cloud_backup": "Sao lưu đám mây", "auto_backup": "Sao lưu tự động", - "data_migration": "Di chuyển dữ liệu" + "data_migration": "Di chuyển dữ liệu", + "csp_rules": "Quy tắc CSP", + "csp_rules_description": "Chỉ xóa tiêu đề phản hồi CSP trên các trang web bạn chọn. Điều này làm yếu lớp bảo vệ tích hợp của các trang đó.", + "csp_rules_scope": "Quy tắc miền bao gồm mọi đường dẫn và miền con, không phụ thuộc vào việc tập lệnh có chạy hay không.", + "csp_rules_reload": "Tải lại các trang bị ảnh hưởng sau khi lưu, bật, tắt hoặc tạm dừng quy tắc.", + "csp_rules_risk": "Một số trang web có thể không hoạt động khi thiếu CSP. Đặc biệt thận trọng với trang web tài chính.", + "csp_rules_trusted_types": "Xóa CSP cũng xóa các chỉ thị Trusted Types.", + "csp_run_rules": "Chạy quy tắc CSP", + "csp_summary_active": "{{count}} quy tắc đang bật · {{websites}} trang web", + "csp_summary_all_sites": "{{count}} quy tắc đang bật · Tất cả trang web", + "csp_summary_paused": "Đã tạm dừng · Giữ lại {{count}} quy tắc", + "csp_apply_error": "Không thể áp dụng quy tắc CSP.", + "csp_apply_error_detail": "Đã lưu quy tắc nhưng trình duyệt có thể vẫn dùng phiên bản trước.", + "csp_browser_error": "Lỗi trình duyệt", + "csp_retry": "Thử lại", + "csp_add_rule": "Thêm quy tắc", + "csp_no_rules": "Chưa có quy tắc CSP", + "csp_no_rules_description": "Chỉ thêm các trang web bị CSP chặn tập lệnh.", + "csp_show_more": "Hiển thị thêm", + "csp_remove_csp": "Xóa CSP", + "csp_all_websites": "Tất cả trang web", + "csp_enabled": "Đã bật", + "csp_disabled": "Đã tắt", + "csp_more_actions": "Thao tác khác", + "csp_edit": "Chỉnh sửa", + "csp_delete": "Xóa", + "csp_delete_description": "Xóa quy tắc CSP này?", + "csp_add_title": "Thêm quy tắc CSP", + "csp_edit_title": "Chỉnh sửa quy tắc CSP", + "csp_websites": "Trang web", + "csp_websites_help": "Bao gồm mọi đường dẫn và miền con.", + "csp_rule_name": "Tên", + "csp_optional": "Tùy chọn", + "csp_enabled_field": "Đã bật", + "csp_advanced_scope": "Phạm vi nâng cao", + "csp_all_sites_warning": "Xóa tiêu đề phản hồi CSP khỏi mọi phản hồi khung chính và khung con phù hợp.", + "csp_confirm_all_sites_title": "Ảnh hưởng đến mọi trang web?", + "csp_confirm_all_sites_description": "Điều này làm yếu bảo vệ tích hợp trên mọi trang web phù hợp. Tiếp tục?", + "csp_confirm": "Tiếp tục", + "csp_cancel": "Hủy", + "csp_save_rule": "Lưu quy tắc", + "csp_rule_saved": "Đã lưu quy tắc. Tải lại các trang bị ảnh hưởng để áp dụng.", + "csp_rule_saved_apply_failed": "Đã lưu quy tắc nhưng không thể cập nhật quy tắc trình duyệt. Hãy thử lại trước khi sử dụng.", + "csp_revision_conflict": "Trang này đã cũ. Tải lại quy tắc mới nhất trước khi lưu.", + "csp_storage_error": "Không thể lưu quy tắc. Nội dung biểu mẫu vẫn được giữ lại.", + "csp_load_error": "Không thể tải quy tắc CSP. Hãy thử lại.", + "csp_unsupported_schema": "Định dạng dữ liệu quy tắc CSP này chưa được hỗ trợ.", + "csp_error_domain_required": "Nhập ít nhất một trang web.", + "csp_error_domain_invalid": "Nhập tên miền trang web HTTP(S) hợp lệ.", + "csp_error_domain_credentials": "Xóa tên người dùng và mật khẩu khỏi URL.", + "csp_error_domain_wildcard": "Nhập example.com; miền con sẽ được bao gồm tự động.", + "csp_error_domain_single_label": "Nhập miền có dấu chấm, chẳng hạn example.com.", + "csp_error_domain_too_long": "Tên miền này quá dài.", + "csp_error_domain_count_invalid": "Dùng từ 1 đến 100 miền cho mỗi quy tắc.", + "csp_error_unique_domain_count_invalid": "Đã đạt giới hạn 1.000 miền.", + "csp_error_domain_duplicate": "Xóa các miền trùng lặp.", + "csp_error_target_invalid": "Chọn phạm vi trang web hợp lệ.", + "csp_error_rule_name_invalid": "Tên phải có tối đa 80 ký tự.", + "csp_error_boolean_invalid": "Chọn bật hoặc tắt.", + "csp_error_patch_empty": "Thay đổi ít nhất một trường.", + "csp_error_patch_field_invalid": "Không thể thay đổi trường này tại đây.", + "csp_error_rule_not_found": "Quy tắc này không còn tồn tại.", + "csp_error_revision_invalid": "Phiên bản quy tắc không hợp lệ.", + "csp_error_rule_count_invalid": "Đã đạt giới hạn 100 quy tắc.", + "csp_error_unsupported_schema": "Định dạng dữ liệu quy tắc CSP này chưa được hỗ trợ.", + "csp_duplicate_domain_notice": "Miền này đã được dùng trong quy tắc khác. Bạn vẫn có thể lưu; cả hai quy tắc sẽ được áp dụng." } diff --git a/src/locales/zh-CN/tools.json b/src/locales/zh-CN/tools.json index 081b7c25f..28c15cbe1 100644 --- a/src/locales/zh-CN/tools.json +++ b/src/locales/zh-CN/tools.json @@ -14,5 +14,70 @@ "local_backup": "本地备份", "cloud_backup": "云端备份", "auto_backup": "自动备份", - "data_migration": "数据迁移" + "data_migration": "数据迁移", + "csp_rules": "CSP 规则", + "csp_rules_description": "只在你选择的网站移除 CSP 响应头。这会削弱这些网站原有的安全保护。", + "csp_rules_scope": "域名规则覆盖所有路径并包含子域名,与脚本是否运行无关。", + "csp_rules_reload": "保存、启用、禁用或暂停规则后,请重新加载受影响的页面。", + "csp_rules_risk": "某些网站会因缺少 CSP 而拒绝工作,金融网站尤其需要谨慎。", + "csp_rules_trusted_types": "移除 CSP 也会移除其中的 Trusted Types 指令。", + "csp_run_rules": "运行 CSP 规则", + "csp_summary_active": "{{count}} 条启用规则 · {{websites}} 个网站", + "csp_summary_all_sites": "{{count}} 条启用规则 · 所有网站", + "csp_summary_paused": "已暂停 · 保留 {{count}} 条规则", + "csp_apply_error": "CSP 规则未能应用。", + "csp_apply_error_detail": "规则已保存,但浏览器可能仍在使用上一个版本。", + "csp_browser_error": "浏览器错误", + "csp_retry": "重试", + "csp_add_rule": "新增规则", + "csp_no_rules": "暂无 CSP 规则", + "csp_no_rules_description": "只添加因 CSP 阻止脚本运行的网站。", + "csp_show_more": "显示更多", + "csp_remove_csp": "移除 CSP", + "csp_all_websites": "所有网站", + "csp_enabled": "已启用", + "csp_disabled": "已禁用", + "csp_more_actions": "更多操作", + "csp_edit": "编辑", + "csp_delete": "删除", + "csp_delete_description": "删除此 CSP 规则?", + "csp_add_title": "新增 CSP 规则", + "csp_edit_title": "编辑 CSP 规则", + "csp_websites": "网站", + "csp_websites_help": "包含所有路径和子域名。", + "csp_rule_name": "名称", + "csp_optional": "可选", + "csp_enabled_field": "启用", + "csp_advanced_scope": "高级范围", + "csp_all_sites_warning": "这会从所有匹配的主框架和子框架响应中移除 CSP 响应头。", + "csp_confirm_all_sites_title": "影响所有网站?", + "csp_confirm_all_sites_description": "这会削弱所有匹配网站的内置保护。是否继续?", + "csp_confirm": "继续", + "csp_cancel": "取消", + "csp_save_rule": "保存规则", + "csp_rule_saved": "规则已保存。请重新加载受影响的页面使其生效。", + "csp_rule_saved_apply_failed": "规则已保存,但浏览器规则未能更新。请重试后再依赖此规则。", + "csp_revision_conflict": "当前页面不是最新状态。请重新加载最新规则后再保存。", + "csp_storage_error": "规则未能保存。表单内容已保留。", + "csp_load_error": "CSP 规则未能加载,请重试。", + "csp_unsupported_schema": "暂不支持此 CSP 规则数据格式。", + "csp_error_domain_required": "至少输入一个网站。", + "csp_error_domain_invalid": "请输入有效的 HTTP(S) 网站域名。", + "csp_error_domain_credentials": "请从网址中移除用户名和密码。", + "csp_error_domain_wildcard": "请输入 example.com;系统会自动包含子域名。", + "csp_error_domain_single_label": "请输入包含点号的域名,例如 example.com。", + "csp_error_domain_too_long": "此域名过长。", + "csp_error_domain_count_invalid": "每条规则请输入 1 至 100 个域名。", + "csp_error_unique_domain_count_invalid": "已达到 1,000 个域名的上限。", + "csp_error_domain_duplicate": "请移除重复域名。", + "csp_error_target_invalid": "请选择有效的网站范围。", + "csp_error_rule_name_invalid": "名称不能超过 80 个字符。", + "csp_error_boolean_invalid": "请选择启用或禁用。", + "csp_error_patch_empty": "至少修改一个字段。", + "csp_error_patch_field_invalid": "此字段不能在这里修改。", + "csp_error_rule_not_found": "此规则已不存在。", + "csp_error_revision_invalid": "规则版本无效。", + "csp_error_rule_count_invalid": "已达到 100 条规则的上限。", + "csp_error_unsupported_schema": "暂不支持此 CSP 规则数据格式。", + "csp_duplicate_domain_notice": "此域名已被其他规则使用,可以保存,但两条规则都会生效。" } diff --git a/src/locales/zh-TW/tools.json b/src/locales/zh-TW/tools.json index 8d473c46c..cba546f00 100644 --- a/src/locales/zh-TW/tools.json +++ b/src/locales/zh-TW/tools.json @@ -14,5 +14,70 @@ "local_backup": "本機備份", "cloud_backup": "雲端備份", "auto_backup": "自動備份", - "data_migration": "資料遷移" + "data_migration": "資料遷移", + "csp_rules": "CSP 規則", + "csp_rules_description": "只在你選擇的網站移除 CSP 回應標頭。這會削弱這些網站原有的安全防護。", + "csp_rules_scope": "網域規則涵蓋所有路徑及子網域,與腳本是否執行無關。", + "csp_rules_reload": "儲存、啟用、停用或暫停規則後,請重新載入受影響的頁面。", + "csp_rules_risk": "部分網站可能因缺少 CSP 而拒絕運作,金融網站尤其需要謹慎。", + "csp_rules_trusted_types": "移除 CSP 也會移除其中的 Trusted Types 指令。", + "csp_run_rules": "執行 CSP 規則", + "csp_summary_active": "{{count}} 條啟用規則 · {{websites}} 個網站", + "csp_summary_all_sites": "{{count}} 條啟用規則 · 所有網站", + "csp_summary_paused": "已暫停 · 保留 {{count}} 條規則", + "csp_apply_error": "CSP 規則無法套用。", + "csp_apply_error_detail": "規則已儲存,但瀏覽器可能仍使用上一個版本。", + "csp_browser_error": "瀏覽器錯誤", + "csp_retry": "重試", + "csp_add_rule": "新增規則", + "csp_no_rules": "沒有 CSP 規則", + "csp_no_rules_description": "只新增因 CSP 阻擋腳本的網站。", + "csp_show_more": "顯示更多", + "csp_remove_csp": "移除 CSP", + "csp_all_websites": "所有網站", + "csp_enabled": "已啟用", + "csp_disabled": "已停用", + "csp_more_actions": "更多操作", + "csp_edit": "編輯", + "csp_delete": "刪除", + "csp_delete_description": "刪除此 CSP 規則?", + "csp_add_title": "新增 CSP 規則", + "csp_edit_title": "編輯 CSP 規則", + "csp_websites": "網站", + "csp_websites_help": "包含所有路徑和子網域。", + "csp_rule_name": "名稱", + "csp_optional": "選填", + "csp_enabled_field": "啟用", + "csp_advanced_scope": "進階範圍", + "csp_all_sites_warning": "這會從所有符合的主框架和子框架回應中移除 CSP 回應標頭。", + "csp_confirm_all_sites_title": "要影響所有網站嗎?", + "csp_confirm_all_sites_description": "這會削弱所有符合網站的內建防護。要繼續嗎?", + "csp_confirm": "繼續", + "csp_cancel": "取消", + "csp_save_rule": "儲存規則", + "csp_rule_saved": "規則已儲存。請重新載入受影響的頁面以套用。", + "csp_rule_saved_apply_failed": "規則已儲存,但瀏覽器規則無法更新。請重試後再依賴它。", + "csp_revision_conflict": "此頁面不是最新狀態。請重新載入最新規則後再儲存。", + "csp_storage_error": "規則無法儲存。已保留表單內容。", + "csp_load_error": "CSP 規則無法載入,請重試。", + "csp_unsupported_schema": "尚未支援此 CSP 規則資料格式。", + "csp_error_domain_required": "請至少輸入一個網站。", + "csp_error_domain_invalid": "請輸入有效的 HTTP(S) 網站網域。", + "csp_error_domain_credentials": "請從網址移除使用者名稱和密碼。", + "csp_error_domain_wildcard": "請輸入 example.com;系統會自動包含子網域。", + "csp_error_domain_single_label": "請輸入包含點號的網域,例如 example.com。", + "csp_error_domain_too_long": "此網域太長。", + "csp_error_domain_count_invalid": "每條規則請使用 1 至 100 個網域。", + "csp_error_unique_domain_count_invalid": "已達到 1,000 個網域的上限。", + "csp_error_domain_duplicate": "請移除重複網域。", + "csp_error_target_invalid": "請選擇有效的網站範圍。", + "csp_error_rule_name_invalid": "名稱不可超過 80 個字元。", + "csp_error_boolean_invalid": "請選擇啟用或停用。", + "csp_error_patch_empty": "請至少修改一個欄位。", + "csp_error_patch_field_invalid": "此欄位無法在此修改。", + "csp_error_rule_not_found": "此規則已不存在。", + "csp_error_revision_invalid": "規則版本無效。", + "csp_error_rule_count_invalid": "已達到 100 條規則的上限。", + "csp_error_unsupported_schema": "尚未支援此 CSP 規則資料格式。", + "csp_duplicate_domain_notice": "此網域已被其他規則使用,可以儲存,但兩條規則都會生效。" } diff --git a/src/pages/options/routes/Tools/categories.ts b/src/pages/options/routes/Tools/categories.ts index e4cddb878..722f3997a 100644 --- a/src/pages/options/routes/Tools/categories.ts +++ b/src/pages/options/routes/Tools/categories.ts @@ -1,4 +1,4 @@ -import { HardDrive, Cloud, CalendarClock, Database, Terminal } from "lucide-react"; +import { HardDrive, Cloud, CalendarClock, Database, ShieldOff, Terminal } from "lucide-react"; import type { TFunction } from "i18next"; import type { SettingsCategory } from "../../layout/SettingsLayout"; @@ -8,6 +8,7 @@ export function getToolsCategories(t: TFunction): SettingsCategory[] { { id: "cloud-backup", icon: Cloud, label: t("tools:cloud_backup") }, { id: "auto-backup", icon: CalendarClock, label: t("tools:auto_backup") }, { id: "data-migration", icon: Database, label: t("tools:data_migration") }, + { id: "csp-rules", icon: ShieldOff, label: t("tools:csp_rules") }, { id: "dev-tools", icon: Terminal, label: t("tools:development_tool") }, ]; } diff --git a/src/pages/options/routes/Tools/index.test.tsx b/src/pages/options/routes/Tools/index.test.tsx index 008f7ee63..ede8736f8 100644 --- a/src/pages/options/routes/Tools/index.test.tsx +++ b/src/pages/options/routes/Tools/index.test.tsx @@ -20,7 +20,18 @@ vi.mock("@App/pages/store/global", () => ({ })); vi.mock("@App/pages/store/features/script", () => ({ synchronizeClient: { export: vi.fn(), backupToCloud: vi.fn() } })); vi.mock("@App/app/migrate", () => ({ migrateToChromeStorage: vi.fn() })); -vi.mock("@App/app/service/service_worker/client", () => ({ SystemClient: vi.fn() })); +vi.mock("@App/app/service/service_worker/client", () => ({ + SystemClient: vi.fn(), + CspRuleClient: vi.fn(function () { + return { + getState: vi.fn().mockResolvedValue({ + state: { schemaVersion: 1, revision: 0, masterEnabled: true, rules: [] }, + apply: { state: "applied", revision: 0, appliedAt: 1 }, + }), + }; + }), + parseCspRuleError: vi.fn(() => ({ code: "storage_write_failed" })), +})); vi.mock("@Packages/filesystem/factory", () => ({ default: { create: vi.fn(), @@ -51,9 +62,9 @@ beforeEach(() => { afterEach(cleanup); describe("工具页", () => { - it("渲染 5 个分类导航项", () => { + it("渲染 6 个分类导航项", () => { render(); const nav = document.querySelector("nav")!; - expect(nav.querySelectorAll("button")).toHaveLength(5); + expect(nav.querySelectorAll("button")).toHaveLength(6); }); }); diff --git a/src/pages/options/routes/Tools/index.tsx b/src/pages/options/routes/Tools/index.tsx index 876cf6a94..3bcb3bd38 100644 --- a/src/pages/options/routes/Tools/index.tsx +++ b/src/pages/options/routes/Tools/index.tsx @@ -5,6 +5,7 @@ import { CloudBackupSection } from "./sections/CloudBackupSection"; import { AutoBackupSection } from "./sections/AutoBackupSection"; import { MigrationSection } from "./sections/MigrationSection"; import { DevToolsSection } from "./sections/DevToolsSection"; +import { CspRulesSection } from "./sections/CspRulesSection"; import { useTranslation } from "react-i18next"; export default function Tools() { @@ -17,6 +18,7 @@ export default function Tools() { + )} diff --git a/src/pages/options/routes/Tools/sections/CspRuleSheet.test.tsx b/src/pages/options/routes/Tools/sections/CspRuleSheet.test.tsx new file mode 100644 index 000000000..a5e68754c --- /dev/null +++ b/src/pages/options/routes/Tools/sections/CspRuleSheet.test.tsx @@ -0,0 +1,64 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { initTestLanguage } from "@Tests/initTestLanguage"; +import { CspRuleSheet } from "./CspRuleSheet"; + +beforeAll(() => initTestLanguage("en-US")); +afterEach(cleanup); + +describe("CSP 规则表单", () => { + it("粘贴完整 URL 后显示规范化域名并提交域名 target", async () => { + const onSave = vi.fn().mockResolvedValue(true); + render( + + ); + + const websites = screen.getByRole("textbox", { name: "Websites" }); + fireEvent.change(websites, { target: { value: "https://Example.com:8443/path" } }); + fireEvent.blur(websites); + expect(await screen.findByText("example.com")).toBeInTheDocument(); + expect(screen.getByText("All paths and subdomains are included.")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Save rule" })); + expect(onSave).toHaveBeenCalledWith({ + name: "", + enabled: true, + target: { type: "domains", domains: ["example.com"] }, + }); + }); + + it("表单错误就地显示且不会提交", () => { + const onSave = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: "Save rule" })); + expect(screen.getByText("Enter at least one website.")).toBeInTheDocument(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("保存失败时保留表单输入", async () => { + const onSave = vi.fn().mockResolvedValue(false); + render( + + ); + const websites = screen.getByLabelText("Websites"); + fireEvent.change(websites, { target: { value: "example.com" } }); + fireEvent.click(screen.getByRole("button", { name: "Save rule" })); + expect(await screen.findByText("The rule could not be saved. Your form entries were kept.")).toBeInTheDocument(); + expect(websites).toHaveValue("example.com"); + }); + + it("所有网站范围提交前要求确认且取消不会调用保存", () => { + const onSave = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: "Advanced scope" })); + fireEvent.click(screen.getByText("All websites", { exact: true })); + fireEvent.click(screen.getByRole("button", { name: "Save rule" })); + expect(screen.getByRole("heading", { name: "Affect all websites?" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/options/routes/Tools/sections/CspRuleSheet.tsx b/src/pages/options/routes/Tools/sections/CspRuleSheet.tsx new file mode 100644 index 000000000..a3535b5ab --- /dev/null +++ b/src/pages/options/routes/Tools/sections/CspRuleSheet.tsx @@ -0,0 +1,255 @@ +import { useMemo, useState, type FormEvent } from "react"; +import { AlertTriangle, ChevronDown } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { CspRule, CspRuleTarget } from "@App/app/repo/csp_rule"; +import { parseCspDomains, type CspDomainParseResult } from "@App/pkg/utils/csp_domain"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@App/pages/components/ui/alert-dialog"; +import { Badge } from "@App/pages/components/ui/badge"; +import { Button } from "@App/pages/components/ui/button"; +import { Checkbox } from "@App/pages/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@App/pages/components/ui/collapsible"; +import { Input } from "@App/pages/components/ui/input"; +import { Label } from "@App/pages/components/ui/label"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@App/pages/components/ui/sheet"; +import { Switch } from "@App/pages/components/ui/switch"; +import { Textarea } from "@App/pages/components/ui/textarea"; + +export type CspRuleFormValue = { + name: string; + enabled: boolean; + target: CspRuleTarget; +}; + +type CspRuleSheetProps = { + open: boolean; + rule: CspRule | undefined; + baseRevision: number; + existingRules: CspRule[]; + onOpenChange: (open: boolean) => void; + onSave: (value: CspRuleFormValue) => Promise; +}; + +function errorText(t: (key: string) => string, messageKey: string): string { + return t(`tools:csp_error_${messageKey}`); +} + +export function CspRuleSheet({ open, rule, existingRules, onOpenChange, onSave }: CspRuleSheetProps) { + const { t } = useTranslation(); + const initialTarget = rule?.target; + const initialDomainTarget = initialTarget && initialTarget.type === "domains" ? initialTarget : undefined; + const [websites, setWebsites] = useState(() => initialDomainTarget?.domains.join("\n") ?? ""); + const [name, setName] = useState(() => rule?.name ?? ""); + const [enabled, setEnabled] = useState(() => rule?.enabled ?? true); + const [allSites, setAllSites] = useState(() => initialTarget?.type === "allSites"); + const [scopeOpen, setScopeOpen] = useState(() => initialTarget?.type === "allSites"); + const [domainResult, setDomainResult] = useState(() => ({ + domains: initialDomainTarget?.domains ?? [], + errors: [], + })); + const [touched, setTouched] = useState(false); + const [submitError, setSubmitError] = useState(""); + const [pendingAllSites, setPendingAllSites] = useState(); + + const duplicateDomains = useMemo(() => { + if (allSites || domainResult.domains.length === 0) return []; + const existing = new Set( + existingRules + .filter((item) => item.id !== rule?.id) + .flatMap((item) => (item.target.type === "domains" ? item.target.domains : [])) + ); + return domainResult.domains.filter((domain) => existing.has(domain)); + }, [allSites, domainResult.domains, existingRules, rule?.id]); + + const validateDomains = (value: string) => { + const result = parseCspDomains(value); + setDomainResult(result); + return result; + }; + + const submit = async (value: CspRuleFormValue) => { + const saved = await onSave(value); + if (!saved) setSubmitError(t("tools:csp_storage_error")); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + setTouched(true); + setSubmitError(""); + const result = allSites ? { domains: [], errors: [] } : validateDomains(websites); + if (!allSites && result.errors.length > 0) return; + if (!allSites && result.domains.length > 100) { + setDomainResult({ + domains: result.domains, + errors: [{ tokenIndex: 0, input: websites, messageKey: "domain_count_invalid" }], + }); + return; + } + const value: CspRuleFormValue = { + name, + enabled, + target: allSites ? { type: "allSites" } : { type: "domains", domains: result.domains }, + }; + if (value.target.type === "allSites" && value.enabled) { + setPendingAllSites(value); + return; + } + void submit(value); + }; + + return ( + <> + + +
+ + {rule ? t("tools:csp_edit_title") : t("tools:csp_add_title")} + {t("tools:csp_rules_description")} + +
+
+ +