Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/app/repo/cspRule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Repo } from "./repo";

export type CSPRuleAction = "remove" | "modify";

export interface CSPRule {
id: string;
name: string;
description: string;
path: string; // 匹配模式(URL/通配符/正则/域名)
action: CSPRuleAction; // "remove" 删除CSP头 或 "modify" 修改为指定值
actionValue?: string; // 当action为modify时的新CSP值
priority: number; // 优先级,数字越大越先匹配
enabled: boolean;
createtime: number;
updatetime: number;
}

/** CSP 全局配置的存储 key */
const CSP_CONFIG_KEY = "cspConfig";

export interface CSPConfig {
globalEnabled: boolean; // 全局开关:开启后直接移除所有 URL 的 CSP 头
}

export class CSPRuleDAO extends Repo<CSPRule> {
constructor() {
super("cspRule");
}

async getAllRules(): Promise<CSPRule[]> {
const rules = await this.find();
return rules.sort((a, b) => b.priority - a.priority);
}

async getEnabledRules(): Promise<CSPRule[]> {
const rules = await this.find((_, value) => value.enabled === true);
return rules.sort((a, b) => b.priority - a.priority);
}

async saveRule(rule: CSPRule): Promise<CSPRule> {
return this._save(rule.id, rule);
}

async deleteRule(id: string): Promise<void> {
return this.delete(id);
}

async updateRule(id: string, changes: Partial<CSPRule>): Promise<CSPRule | false> {
return this.update(id, changes);
}

async getCSPConfig(): Promise<CSPConfig> {
return new Promise((resolve) => {
chrome.storage.local.get(CSP_CONFIG_KEY, (result) => {
if (chrome.runtime.lastError) {
console.error("getCSPConfig error:", chrome.runtime.lastError);
resolve({ globalEnabled: false });
return;
}
const raw = result?.[CSP_CONFIG_KEY];
if (raw) {
try {
resolve(JSON.parse(raw) as CSPConfig);
return;
} catch {
// ignore
}
}
resolve({ globalEnabled: false });
});
});
}

async saveCSPConfig(config: CSPConfig): Promise<void> {
return new Promise((resolve, reject) => {
chrome.storage.local.set({ [CSP_CONFIG_KEY]: JSON.stringify(config) }, () => {
if (chrome.runtime.lastError) {
console.error("saveCSPConfig error:", chrome.runtime.lastError);
reject(new Error(chrome.runtime.lastError.message));
return;
}
resolve();
});
});
}
}
43 changes: 43 additions & 0 deletions src/app/service/service_worker/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { type ScriptInfo } from "@App/pkg/utils/scriptInstall";
import type { ScriptService, TCheckScriptUpdateOption, TOpenBatchUpdatePageOption } from "./script";
import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value";
import { type TSetValuesParams } from "./value";
import { type CSPRule, type CSPConfig } from "@App/app/repo/cspRule";

export class ServiceWorkerClient extends Client {
constructor(msgSender: MessageSend) {
Expand Down Expand Up @@ -328,3 +329,45 @@ export class SystemClient extends Client {
return this.do("connectVSCode", params);
}
}

export class CSPRuleClient extends Client {
constructor(msgSender: MessageSend) {
super(msgSender, "serviceWorker/cspRule");
}

getAllRules(): Promise<CSPRule[]> {
return this.doThrow("getAllRules");
}

getEnabledRules(): Promise<CSPRule[]> {
return this.doThrow("getEnabledRules");
}

getCSPConfig(): Promise<CSPConfig> {
return this.doThrow("getCSPConfig");
}

toggleGlobal(enabled: boolean): Promise<CSPConfig> {
return this.doThrow("toggleGlobal", { enabled });
}

createRule(rule: Omit<CSPRule, "id" | "createtime" | "updatetime">): Promise<CSPRule> {
return this.doThrow("createRule", rule);
}

updateRule(params: { id: string; changes: Partial<CSPRule> }): Promise<CSPRule | false> {
return this.doThrow("updateRule", params);
}

deleteRule(id: string): Promise<void> {
return this.do("deleteRule", id);
}

toggleRule(params: { id: string; enabled: boolean }): Promise<CSPRule | false> {
return this.doThrow("toggleRule", params);
}

reorderRules(ruleIds: string[]): Promise<void> {
return this.do("reorderRules", ruleIds);
}
}
257 changes: 257 additions & 0 deletions src/app/service/service_worker/cspInterceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { CSPRuleDAO, type CSPRule } from "@App/app/repo/cspRule";
import type Logger from "@App/app/logger/logger";
import LoggerCore from "@App/app/logger/core";
import { toDeclarativeNetRequestFilter } from "@App/pkg/utils/patternMatcher";

const CSP_RULE_ID_START = 10000;
const MAX_DYNAMIC_RULES = 5000;

export class CSPInterceptorService {
private logger: Logger;
private cspRuleDAO: CSPRuleDAO;
private enabledRules: CSPRule[] = [];
private globalEnabled: boolean = false;
private initialized: boolean = false;
private ruleIdCounter: number = CSP_RULE_ID_START;

constructor() {
this.logger = LoggerCore.logger().with({ service: "cspInterceptor" });
this.cspRuleDAO = new CSPRuleDAO();
}

/**
* 初始化拦截器
* 加载已有的启用规则并注册到 Chrome DNR
*/
async init() {
if (this.initialized) {
return;
}
this.initialized = true;

// 清除历史遗留的脏数据
try {
const existingRules = await chrome.declarativeNetRequest.getDynamicRules();
const cspRuleIds = existingRules
.filter((rule) => rule.id >= CSP_RULE_ID_START && rule.id < CSP_RULE_ID_START + MAX_DYNAMIC_RULES)
.map((rule) => rule.id);
if (cspRuleIds.length > 0) {
this.logger.info("cleaning up legacy CSP dynamic rules", { count: cspRuleIds.length });
await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: cspRuleIds,
});
}
} catch (e) {
this.logger.warn("failed to cleanup legacy CSP rules", { error: String(e) });
}

// 加载全局配置和已有规则
const config = await this.cspRuleDAO.getCSPConfig();
this.globalEnabled = config.globalEnabled;
this.enabledRules = await this.cspRuleDAO.getEnabledRules();
this.logger.info("csp interceptor initialized", {
globalEnabled: this.globalEnabled,
ruleCount: this.enabledRules.length,
});

await this.updateDeclarativeRules();
}

/**
* 更新启用的规则列表并重新注册 DNR 规则
* 由 CSPRuleService 在规则变更时直接调用
*/
async updateRules(enabledRules: CSPRule[]) {
this.enabledRules = enabledRules;
this.logger.info("csp rules updated", { ruleCount: enabledRules.length });
await this.updateDeclarativeRules();
}

/**
* 更新全局开关状态
*/
async updateGlobalEnabled(enabled: boolean) {
this.globalEnabled = enabled;
this.logger.info("csp global enabled changed", { globalEnabled: enabled });
await this.updateDeclarativeRules();
}

private async updateDeclarativeRules() {
try {
const existingRules = await chrome.declarativeNetRequest.getDynamicRules();
const existingRuleIds = existingRules
.filter((rule) => rule.id >= CSP_RULE_ID_START && rule.id < CSP_RULE_ID_START + MAX_DYNAMIC_RULES)
.map((rule) => rule.id);

this.logger.info("updating declarative rules", {
removeCount: existingRuleIds.length,
globalEnabled: this.globalEnabled,
enabledCount: this.enabledRules.length,
});

const newRules: chrome.declarativeNetRequest.Rule[] = [];
this.ruleIdCounter = CSP_RULE_ID_START;

// 全局模式:注册一条全量移除规则
if (this.globalEnabled) {
newRules.push({
id: this.ruleIdCounter++,
priority: 9999,
action: {
type: "modifyHeaders" as chrome.declarativeNetRequest.RuleActionType,
responseHeaders: [
{
header: "Content-Security-Policy",
operation: "remove" as chrome.declarativeNetRequest.HeaderOperation,
},
{
header: "Content-Security-Policy-Report-Only",
operation: "remove" as chrome.declarativeNetRequest.HeaderOperation,
},
{
header: "X-Content-Security-Policy",
operation: "remove" as chrome.declarativeNetRequest.HeaderOperation,
},
{
header: "X-WebKit-CSP",
operation: "remove" as chrome.declarativeNetRequest.HeaderOperation,
},
],
},
condition: {
urlFilter: "*",
resourceTypes: ["main_frame", "sub_frame"] as chrome.declarativeNetRequest.ResourceType[],
},
});
// 全局模式下不需要注册具体规则
} else {
// 规则模式:按优先级注册各规则
const sortedRules = [...this.enabledRules].sort((a, b) => b.priority - a.priority);

for (const rule of sortedRules) {
const dnrRules = this.convertToDeclarativeRule(rule);
if (dnrRules) {
for (const dnrRule of dnrRules) {
if (newRules.length >= MAX_DYNAMIC_RULES) {
this.logger.warn("max dynamic rules limit reached", { limit: MAX_DYNAMIC_RULES });
break;
}
newRules.push(dnrRule);
}
}
if (newRules.length >= MAX_DYNAMIC_RULES) {
break;
}
}
}

await chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: existingRuleIds,
addRules: newRules,
});

this.logger.info("declarative rules updated successfully", {
addedCount: newRules.length,
});
} catch (e) {
this.logger.error("failed to update declarative rules", { error: String(e) });
}
}

private convertToDeclarativeRule(rule: CSPRule): chrome.declarativeNetRequest.Rule[] | null {
if (!rule.enabled) {
return null;
}

const conditions = this.buildConditions(rule.path);
if (conditions.length === 0) {
this.logger.warn("could not build condition for rule", { ruleName: rule.name, path: rule.path });
return null;
}

const dnrRules: chrome.declarativeNetRequest.Rule[] = [];

for (const condition of conditions) {
const ruleId = this.ruleIdCounter++;
// Chrome DNR 要求 priority >= 1
const dnrPriority = Math.max(1, rule.priority);

if (rule.action === "remove") {
dnrRules.push({
id: ruleId,
priority: dnrPriority,
action: {
type: "modifyHeaders" as chrome.declarativeNetRequest.RuleActionType,
responseHeaders: [
{
header: "Content-Security-Policy",
operation: "remove" as chrome.declarativeNetRequest.HeaderOperation,
},
{
header: "Content-Security-Policy-Report-Only",
operation: "remove" as chrome.declarativeNetRequest.HeaderOperation,
},
{
header: "X-Content-Security-Policy",
operation: "remove" as chrome.declarativeNetRequest.HeaderOperation,
},
{
header: "X-WebKit-CSP",
operation: "remove" as chrome.declarativeNetRequest.HeaderOperation,
},
],
},
condition: {
...condition,
resourceTypes: ["main_frame", "sub_frame"] as chrome.declarativeNetRequest.ResourceType[],
},
});
} else if (rule.action === "modify" && rule.actionValue) {
dnrRules.push({
id: ruleId,
priority: dnrPriority,
action: {
type: "modifyHeaders" as chrome.declarativeNetRequest.RuleActionType,
responseHeaders: [
{
header: "Content-Security-Policy",
operation: "set" as chrome.declarativeNetRequest.HeaderOperation,
value: rule.actionValue,
},
],
},
condition: {
...condition,
resourceTypes: ["main_frame", "sub_frame"] as chrome.declarativeNetRequest.ResourceType[],
},
});
}
}

return dnrRules;
}

private buildConditions(pattern: string): Partial<chrome.declarativeNetRequest.RuleCondition>[] {
const conditions: Partial<chrome.declarativeNetRequest.RuleCondition>[] = [];

try {
const filter = toDeclarativeNetRequestFilter(pattern);

if (filter.regexFilter) {
conditions.push({
regexFilter: filter.regexFilter,
});
this.logger.debug("using regex filter", { pattern, regexFilter: filter.regexFilter });
} else if (filter.urlFilter) {
conditions.push({
urlFilter: filter.urlFilter,
});
this.logger.debug("using url filter", { pattern, urlFilter: filter.urlFilter });
}
} catch (e) {
this.logger.warn("invalid pattern", { pattern, error: String(e) });
}

return conditions;
}
}
Loading
Loading