[TOC]
目前扩展开发还是建议先大概读一下官方文档,大致了解扩展的基本结构和可以使用的API,再根据自己的需求查看对应API的详细调用格式。
下面会根据一个简单的编码/解码扩展实现来讲述一下安全扩展快速开发可能涉及到的内容。
简单来说,Chrome扩展是由清单文件manifest.json、扩展运行逻辑脚本JS文件、资源文件(如HTML、CSS、图片文件等,非必须)组成的。
按照Chrome扩展的要求,清单文件manifest.json必须放置在扩展文件夹的根目录,除此以外扩展对其他的js或资源文件没有任何的目录要求(对于Manifest V3来说,background javascript文件必须与清单文件同样放置于扩展根目录)。
以下为Github某插件项目的文件结构:
完整的清单文件的选项可以查看官方文档中的模板: https://developer.chrome.com/docs/extensions/mv2/manifest/
清单文件manifest.json决定了浏览器以怎样的方式处理扩展的JS与资源文件,这些配置将以json键值对的形式被解析,下面是一个清单文件的简单示例,开发者可以通过自己的需求对非必须字段进行删改。
{ // 插件名称 "name": "BrowerToolkit", // 插件作者 "author": "Ghroth", // 插件版本 "version": "1.0", // 插件架构 "manifest_version": 2, // 插件描述 "description": "Encode/Decode、Set Cookie", // 插件图标 "icons": { "16": "resource/img/ico.png", "64": "resource/img/ico.png" }, // 插件后台运行脚本 "background": { // 始终运行,不会在空闲时休眠 "persistent": true, // 后端运行脚本路径 "scripts": ["resource/js/background.js"] }, // 浏览器右上角插件栏设置 "browser_action": { // 将鼠标悬停在操作图标上时向用户提供简短说明 "default_title": "BrowerToolkit", // 插件栏图标 "default_icon": "resource/img/ico.png", // 插件栏点击后弹出页面地址 "default_popup": "resource/html/popup.html" }, "permissions": [ // API的使用权限 "contextMenus", // 对相应网站的访问权限 "http://*/*", "https://*/*" ], // CSP // 需要内联CSS生效的,style-src后面添加'unsafe-inline' // 需要进行web3模板生成的,script-src后面添加'unsafe-eval' "content_security_policy": "style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; object-src 'self';" }
后台脚本在插件中处于一个服务端的角色,由指定的HTML页面或JS文件承担,开发者一般将需要后台持续运行的代码逻辑放置在后台脚本中。
后台脚本通过清单文件中的background键进行配置,子键page决定了后台页面的路径,子键scripts决定了后台脚本的路径(该子键和page互斥,传入参数为字符串数组),子键persistent决定了脚本是否持续运行(即在不使用时休眠以节约浏览器资源)。
需要注意的是,当persistent的值为false,即后台脚本不持续运行时,后台脚本中的JS全局变量是不可靠的,其保存的值会在脚本休眠时被删除(这一机制的类似模式在Manifest V3中成为强制要求),此时可以使用Chrome提供的storage接口进行数据的同步或异步的保存。当persistent的值为true时,开发者可以放心的使用后台脚本中的全局变量,他们会符合正常逻辑的在浏览器开启或扩展刷新(即后台脚本加载)时被定义,浏览器关闭时(即后台脚本移除)时被移除。
下面示例为后台脚本background.js的代码,它将在插件安装时使用contextMenus API在网页的右键菜单中添加相关可选项,API的用法将会在Chrome API调用这部分进行详细解释。
chrome.runtime.onInstalled.addListener(function () { chrome.contextMenus.create({title: "WebToolkit", id: "WebToolkit", enabled: true}, function(){ chrome.contextMenus.create({title: "对选中内容进行base64解码", parentId: "WebToolkit"}); }); })
清单文件中的browser_action键则定义了一个出现在扩展栏的图标,它接受default_title、default_icon、default_popup等子键来分别决定图标标题(鼠标悬浮时显示的文字描述)、图标图片和点击时弹出的popup页面。
类似的键还有page_action,它接受的子键与browser_action类似,但是此类图标并非一直可用,通过pageAction或declarativeContent等API决定此图标在某些页面上是否可用。对于低版本的Chrome来说,page_action最早设计出现在地址栏尾部,后来高版本Chrome将其移至扩展栏处(即与browser_action一致)。
对于弹出页面popup来说,这个页面将是大多数扩展进行数据展示和用户逻辑处理的页面。它将在点击工具栏图标时加载和渲染相关元素,初始化内部JS代码。用户在点击popup以外的浏览器界面时将会关闭popup,此时所有的元素和变量都将被销毁。以下为扩展Wappalyzer的弹出页面。
下面是示例扩展在popup中的代码,它通过html和js定义了一个进行编码转换的输入框,可用通过选择编码方式进行编码解码。
<body> <textarea rows="10" cols="50" placeholder="待处理文本" id="codetext"></textarea> <select id="codeselect" >......</select> <input type="button" value="处理" id="codebutton"> <script type="text/javascript" src="../js/popup.js"></script> </body>
# popup.js var codetextarea = null var atext = "" var btext = "" var stext = "" var codebutton = document.getElementById("codebutton") codebutton.onclick = codetext function codetext() { atext = "" btext = "" stext = "" codetextarea = document.getElementById("codetext") atext = codetextarea.value stext = document.getElementById("codeselect").value if (atext != "" && stext != "") { switch (stext) { case "0": // Base64 Decode btext = atob(atext) break; case "1": // Base64 Decode(中文UTF8+Url编码处理) btext = decodeURIComponent(atob(atext)) break; ...... default: btext = "" } } if (btext != "") { codetextarea.value = btext } }
需要注意的是,这里的popup页面文件内部无法使用<script>
标签或onclick
属性来执行或指定相关js方法,因为清单文件manifest.json中规定了CSP的script-src,禁止了内联js的使用,可以通过修改CSP或引用外部js文件解决。
# 报错代码 <input type="button" value="处理" onclick="codetext" > # 报错内容 Refused to execute inline event handler because it violates the following Content Security Policy directive: "script-src 'self' 'unsafe-eval'". Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present. # 修改后代码 <input type="button" value="处理" id="cbutton"> <script type="text/javascript" src="../js/popup.js"></script> # popup.js var codebutton = document.getElementById("codebutton") codebutton.onclick = codetext
当开发者使用Vue等新一代前端框架及其配套的UI框架时(这里以Vue为例),可能会涉及扩展的特性进行针对处理,以下面引入框架为例进行讲解:
一般引入Vue框架有两种方式,Vue脚手架或者CDN/本地引入global.js文件。
首先脚手架方式引入的话,是由脚手架进行Vue模板的编译工作,此时无需额外处理,需要了解的是,默认的发布生产版本将会将生产版本放置在默认的dist文件夹,开发者可以使用修改配置文件的build模块outDir参数修改生成目录到扩展的相关页面对应的目录下。
// 安装并执行 create-vue, 创建Vue项目 npm init [email protected] // 进入对应的Vue项目,按照依赖并启动在线开发端口 cd projectname npm install npm run dev // 发布生产环境 npm run build
在实时修改代码后,dev开发端口开启的web业务的页面内容会实时更新,但是插件目录的部署文件仍然需要手动执行命令来生成,为了解决这个问题,可以使用nodemon库来监控代码修改和自动编译文件到插件目录。
npm install nodemon # package.json scripts键 "scripts": { "dev": "vite", "build": "vite build", // 监控代码修改和自动编译 "build:watch": "nodemon --watch src --exec npm run build --ext \"ts,vue\"", "preview": "vite preview --port 4173" }, npm run build:watch
当使用CDN/本地引入global.js文件的方式引入Vue框架时,使用默认的CSP可能会导致无法使用eval用于Vue编译模板文件导致报错,此时可以在script-src
中添加'unsafe-eval'
来允许页面加载时编译自身Vue模板内容。
// 默认CSP
"content_security_policy": "style-src 'self'; script-src 'self'; object-src 'self';"
// 修改后CSP
"content_security_policy": "style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; object-src 'self';"
Chrome API为浏览器为开发者开放的相关接口,绝大多数的扩展改变浏览器原有行为都是通过调用Chrome API实现的。开发者可以根据自身需求调用Chrome API或通过JS自行实现来实现预期的扩展功能。
Manifest V2支持的API类型如下表(机翻):
API权限声明 | 概述 |
---|---|
自定义扩展用户界面 | |
浏览器操作 | 将图标、工具提示、锁屏提醒和弹出窗口添加到工具栏。 |
命令 | 添加触发操作的键盘快捷键。 |
上下文菜单 | 将项目添加到谷歌浏览器的上下文菜单中。 |
多功能盒 | 将关键字功能添加到地址栏。 |
覆盖页面 | 创建“新标签页”、“书签”或“历史记录”页面的新版本。 |
页面操作 | 在工具栏中动态显示图标。 |
构建扩展实用程序 | |
可访问性(a11y) | 使扩展程序可供残障人士访问。 |
背景脚本 | 当有趣的事情发生时,检测并做出反应。 |
国际化 | 使用语言和区域设置。 |
身份 | 获取 OAuth2 访问令牌。 |
管理 | 管理已安装和正在运行的扩展。 |
消息传递 | 从内容脚本到其父扩展进行通信,反之亦然。 |
选项页面 | 允许用户自定义扩展。 |
权限 | 修改扩展程序的权限。 |
存储 | 存储和检索数据。 |
修改和观察浏览器 | |
书签 | 创建、组织和操作书签行为。 |
浏览数据 | 从用户的本地配置文件中删除浏览数据。 |
下载 | 以编程方式启动、监视、操作和搜索下载。 |
字体设置 | 管理浏览器的字体设置。 |
历史 | 与浏览器的已访问页面记录进行交互。 |
隐私 | 控制浏览器隐私功能。 |
代理 | 管理浏览器的代理设置。 |
会话 | 从浏览会话查询和还原选项卡和窗口。 |
标签页 | 在浏览器中创建、修改和重新排列选项卡。 |
热门网站 | 访问用户访问量最大的 URL。 |
主题 | 更改浏览器的整体外观。 |
窗口 | 在浏览器中创建、修改和重新排列窗口。 |
修改和观察网络 | |
活动选项卡 | 通过消除对主机权限的大多数需求来安全地访问网站。<all_urls> |
内容设置 | 自定义网站功能,如Cookie、脚本和插件。 |
内容脚本 | 在网页上下文中运行脚本代码。 |
Cookie | 浏览和修改浏览器的 Cookie 系统。 |
跨域 XHR | 使用 XMLHttp 请求从远程服务器发送和接收数据。 |
声明性内容 | 对页面内容执行操作,无需权限。 |
桌面捕获 | 捕获屏幕、单个窗口或选项卡的内容。 |
页面捕获 | 将选项卡的源代码信息另存为 MHTML。 |
选项卡捕获 | 与选项卡媒体流交互。 |
网站导航 | 动态导航请求的状态更新。 |
网络请求 | 观察和分析流量。拦截阻止或修改正在进行的请求。 |
打包、部署和更新 | |
浏览器网上应用商店 | 使用 Chrome 网上应用商店托管和更新扩展程序。 |
其他部署选项 | 在指定网络上或与其他软件一起分发扩展。 |
拓展浏览器开发工具 | |
调试器 | 检测网络交互,调试脚本,改变 DOM 和 CSS。 |
开发工具 | 向 Chrome 开发者工具添加功能。 |
找到需要调用的API后,需要首先在manifest.json的permissions键中声明权限,才能使用对应的API方法。permissions键接受一个字符数组作为值,数组内容为所有需要调用的API权限声明或扩展需要访问的站点地址。
"permissions": [ // API的使用权限 "webRequest", "webRequestBlocking", // 对相应网站的访问权限 "http://*/*", "https://*/*" ],
以调用网络方面常用的API chrome.webRequest
完成某些页面的阻断为例,代码附后:
webRequest
以外还需要声明网络活动阻断权限webRequestBlocking
。chrome.webRequest.onBeforeRequest.addListener
),Listener将在浏览器访问指定网页(dnslog.cn及其子域名
)时调用回调函数(此处为了简洁方便使用匿名函数代替)。blocking
,拦截本次请求。chrome.webRequest.onBeforeRequest.addListener( function(details) { return {cancel: true}; }, {urls: ["*://dnslog.cn/","*://*.dnslog.cn/"]}, ["blocking"] );
当完成以上内容后,可以将扩展加载到Chrome浏览器,以开发者模式运行未发布到谷歌商店的扩展。步骤如下:
1、浏览器界面-更多-其他工具-我的扩展或者直接访问URL chrome://extensions/ 打开扩展页面
2、开启右上角的开发者模式
3、点击左上角的加载已解压的扩展程序,加载的文件夹路径为扩展的根目录(即清单文件manifest.json所在的文件夹路径)
4、此时扩展将会被加载。
需要注意的是,开放者模式下加载已解压的文件夹时,扩展根目录如果被删除,插件的静态文件会不可用,插件将在下一次启动浏览器时自动删除。
在开启开发者模式的情况下,可以在扩展界面看到每个插件都有一个ID显示,这个ID将作为插件在浏览器中的唯一标识符使用,这个ID将根据插件的根目录文件夹的名称生成。而应用商店下载的插件将会被解压至C:\Users\用户名\AppData\Local\Google\Chrome\User Data\Default\Extensions\扩展ID
文件夹下(Windows7 及其以上版本),此时ID为扩展的公钥哈希的一部分。
调试popup页面时,可以在单击扩展栏图标显示的popup页面上右键检查,继而打开devtools进行相关popup页面的代码逻辑的调试工作。正如前面所说,用户在点击popup以外的浏览器界面时将会关闭popup,此时所有的元素和变量都将被销毁。所以调试期间请保持popup页面的展示。事实上,如果打开开发者工具后,扩展的popup页面并不会因为点击范围外空白处而关闭,这是为了保持调试页的存在,但是开发者仍然可以通过点击扩展图标或切换当前标签页来关闭popup。
此时即可在devtools的源代码界面进行语句下断点,对popup中的代码逻辑进行调试,使用跟其他语言的IDE没有太大区别。
调试后台页面或后台脚本时,可以通过扩展页面的链接跳转到后台脚本的调试窗口,此时可以通过element、console、network等devtools去调试对应的后台界面,其代码调试与popup的调试没有太大区别。。与之相对的,虽然后台页面有一个固定的链接地址(chrome-extension://扩展哈希/扩展路径,如果使用的是后台脚本,扩展路径将会使用默认html地址_generated_background_page.html
),但是当你直接访问这个路径时,并不会打开真正的起服务端作用的后台脚本页面,而是打开了一个新的html页面。
background和popup分别是扩展的后台逻辑和用户交互的重要载体,但是需要注意的是扩展无法通过这两者直接控制用户访问的某个页面的显示内容(即无法控制页面DOM),此时可以通过内容脚本注入的方式注入相关脚本,这些脚本将被符合规则的页面加载,如同被此页面使用的其他脚本文件一样。
内容脚本注入一般有两种途径,即声明式注入和编程式注入,两者的区别就是注入行为发生在清单文件manifest.json还是逻辑脚本中。
声明式注入即类似于popup一般,在清单文件manifest.json中提前声明需要注入的脚本路径、注入脚本的条件等等,此外无需额外操作即完成了内容脚本的注入。
声明式脚本通过content_scripts键进行定义,它的matches子键、css子键、js子键分别声明了注入条件、需要注入的css文件和需要注入的js文件。一些键值对的细节可以查看文档进行配置: https://developer.chrome.com/docs/extensions/mv2/content_scripts/#declaratively
{ "name": "My extension", ...... "content_scripts": [ { "matches": ["http://*.nytimes.com/*"], "css": ["myStyles.css"], "js": ["contentScript.js"] } ], ... }
编程式注入即利用Chrome API进行方法调用,进而注入相应脚本。这种注入方式的优点胜在灵活方便。使用编程式注入需要声明权限activeTab
。开发者可以注入相关脚本内容,也可以直接注入某个指定的JS文件。示例如下:
// 注入一句js代码 chrome.tabs.executeScript({ code: 'document.body.style.backgroundColor="orange"' }); // 注入指定的js文件 chrome.tabs.executeScript({ file: 'contentScript.js' });
内容脚本是在网页上下文中运行的文件,通过使用标准文档对象模型 (DOM),它们能够读取浏览器访问的网页的详细信息,对其进行更改并将信息传递给其父扩展。但是需要注意的是,内容脚本本身运行在浏览器的一个沙箱中,它虽然能够访问页面DOM,但是沙箱隔离使它与页面本身的js代码并不能直接发生交互。
当需要对页面的js内容进行相关操作,可以利用内容脚本可以操作DOM的特性,使用DOM附加一个scirpt标签到页面中,进而即可将对应的js文件加载的此标签上,实现对页面JS对象的访问。
const script = document.createElement('script'); script.src = chrome.runtime.getURL("content.js"); document.documentElement.appendChild(script);
当用户界面popup和后台脚本background需要对彼此进行相关变量或方法的读取或调用时,只需要使用extension权限的API进行相应页面的javascript对象获取即可,具体代码如下:
// popup获取background的javascript对象,此时可以直接进行属性和方法调用 chrome.extension.getBackgroundPage() chrome.extension.getBackgroundPage().var1 = "test" chrome.extension.getBackgroundPage().method1() // background获取插件所有页面的javascript对象,注意返回值为数组 chrome.extension.getViews() // 开发者需要寻找chrome.extension.getViews()的返回值数组中location字段的href字段为相应popup.html的地址的元素 chrome.extension.getViews({type:"popup"})[0].var2 = "test" chrome.extension.getViews({type:"popup"})[0].method2()
下面简单讲述一些内容脚本与扩展页面通信可能用到的方法,内容脚本中的通信细节可以参考文档: https://developer.chrome.com/docs/extensions/mv2/messaging/
如果只需要将单个消息发送到扩展的另一部分(并选择性地获取回复),则应使用简单的runtime.sendMessage
或 tabs.sendMessage
。这两个方法可以分别将一次性 JSON 可序列化消息从内容脚本发送到扩展,反之亦然。可选的回调参数允许开发者编写如何处理来自另一端的响应(如果有)。
// 从内容脚本发送请求 chrome.runtime.sendMessage({greeting: "hello"}, function(response) { console.log(response.farewell); }); // 从扩展程序向内容脚本发送请求非常相似,只是需要指定要将其发送到哪个选项卡 chrome.tabs.query({active: true, currentWindow: true}, function(tabs) { chrome.tabs.sendMessage(tabs[0].id, {greeting: "hello"}, function(response) { console.log(response.farewell); }); }); // 在接收端,需要设置一个runtime.onMessage 事件侦听器来处理该消息。这在内容脚本或扩展页面中用法一致。 chrome.runtime.onMessage.addListener( function(request, sender, sendResponse) { console.log(sender.tab ? "from a content script:" + sender.tab.url : "from the extension"); if (request.greeting == "hello") sendResponse({farewell: "goodbye"}); } );
类似的,当需要长期进行规律性的(非必须)的通信时,可以使用 runtime.connect 或 tabs.connect 建立一个长期通信连接进行消息通信;涉及扩展间的消息通信时可以使用runtime.onMessageExternal 或 runtime.onConnectExternal 进行。
需要注意的是,内容脚本返回内容的并不是绝对安全的,攻击者可能根据扩展的代码中的消息通信参数,主动构建相关恶意消息通信请求来调用扩展的部分代码逻辑,所以对内容脚本返回的内容需要开发者通过一定方式进行过滤和验证,尽量避免直接接受消息调用关键逻辑或直接将消息通信内容加载进js或DOM。
示例为在百度主页的F12开发者工具控制栏中进行消息发送,成功调用出了某插件的弹窗告警,攻击者可以通过此机制令某些网页对安装了特定扩展的用户进行持续性的弹窗告警。
相应的变更细节建议对照文档进行查看: https://developer.chrome.com/docs/extensions/mv3/mv3-migration/
就目前来说(2022.10.1),Manifest V2至少还有一年半左右的可用期,而且大部分扩展仍未进行架构的升级,加上Manifest V3仍然存留部分Bug未解决,所以开发时Manifest的选择还是可以不用对Manifest V3过于急迫。
官方文档中描述Manifest V3中仍然存在的Bug:https://developer.chrome.com/docs/extensions/mv3/known-issues/#bugs
manifest_version
键的值应该变更为3,表示这是一个Manifest V3的扩展。service_worker
下注册服务工作线程,这个字段接受一个js文件路径。跟Manifest V2不同的是,不再接受HTML页面作为背景脚本,也不允许背景脚本持久的运行在后台。permissions
键中与API一起申请变更为了使用单独的键host_permissions
进行声明。content_security_policy
接受一个字符串值变更为了接受一个对象,接受extension_pages
和sandbox
来配置相关细节。browser_action
和page_action
统一为了action
。MV2 - background | MV3 - Service Worker |
---|---|
可以使用持久性页面。 | 不使用时终止。 |
有权访问 DOM。 | 无权访问 DOM。 |
可以使用 .XMLHttpRequest() | 必须使用 fetch() 来发出请求。 |
webRequestBlocking
被声明式的网络请求declarativeNetRequest
操作代替,这大大降低了操作的灵活性,扩展不是读取请求并以编程方式更改它,而是指定许多规则在符合条件时进行提前声明的操作。Service Worker
代替后台脚本background
后,由于不使用时休眠的特性,原background脚本内所有的全局变量都将在脚本休眠时销毁,开发者必须使用storage权限来进行异步的保存或读取相关数据。另外定时函数setTimeout
和setInterval
也因为后台页面的休眠而无法在background中正常使用,相应替换为alarm API进行操作。declarativeNetRequestwebRequestBlocking
代替网络请求拦截webRequestBlocking
后,只能通过预先设置的规则对网络请求进行操作,且规则条数存在上限,与原先可以通过编程进行处理的API相比,灵活性大大降低,而且编写复杂度也提高了。