iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 17
0
Software Development

自己用的工具自己做! 30天玩轉VS Code Extension之旅系列 第 17

Day17 | WebView API (三)

大家好,我是韋恩,今天是第十七天,我們會繼續了解如何在VSCode使用Webview。

今天,我們會來學習如何載入本地資源與Javascript,這些教學是幫助我們進一步整合當前流行的SPA前端框架至WebView的基礎。

使用本地資源(Local Resource)


在前面的文章裡,我們已經了解怎麼在WebView裡使用Javascript,但在實務上,通常我們不會將Javascript跟Html混在一起寫,style也會跟javascript一起抽出去成為獨立的檔案。在關注點分離的狀況下,專案整體可以更好維護,也更好擴充。

現在我們就將script檔案從webview.html抽出,原本的html改為:

<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
 <title>Webview Practice</title>
</head>
<body>
 <h1 style="color: white;">Hello Webview!</h1>
 <img src="https://i.imgur.com/hzK0JDS.jpg" width="50%" />
 <script src="./webview.js"></script>
</body>
</html>

抽出去的webview.js檔內容則是

const $h1 = document.querySelector('h1');
$h1.innerHTML = 'Hello Webview and Javascript!';

現在我們的檔案結構如下:

接著我們調整前面範例的extension.ts裡的載入html路徑:

webviewPanel.webview.html = loadWebView("./webview/webview.html");

執行extension,會發現script調整過路徑後再也不起作用。

為什麼呢?原來,VSCode基於安全的考量,限制webview裡的程式無法直接存取本地資源(Local Resouces),除非使用vscode自己的webviewUri讀取。因此不管是圖片、icon還是style或script檔案,都沒有辦法像原本的前端直接使用相對路徑。

為此,我們在extension這一端,就需要先使用VSCode的Uri方法,將原本需載入路徑的字串轉成VSCode.Uri物件。

const vsUri = vscode.Uri.parse(
    path.join(context.extensionPath, 'src/webview/webview.js')
);

這個Uri物件的格式為:

{
    $mid:1
    path:"${yourRootPath}/day17-webview-spa-practice/src/webview/webview.js"
    scheme:"file"
}

但有了VSCode的Uri物件還不夠,我們還需要再提供Uri物件給webview物件,轉換成webviewUri,如下所示:

const webviewUri = webviewPanel.webview.asWebviewUri(vsUri);

這個webviewUri一樣是個vscode.Uri物件,內容格式如下:

{
    $mid:1
    path:"/file///${yourRootPath}/day17-webview-spa-practice/src/webview/webview.js"
    scheme:"vscode-webview-resource"
    authority:"${authorityUrl}" // 此處筆者的url為bc1d4f5e-6f25-4f26-89b8-9deaf9b75116
}

可以看到轉換後的uri的schema型態已經改為vscode-webview-resource,並且指定了一個authority的編號屬性。

如果我們於webview.js多加一段方法監聽extension傳來的訊息

window.addEventListener('message', (e) => {
console.log(e.origin); 
});

並在extension部分調用postMessage傳任意訊息

則可以透過e.origin讀取到origin的url為vscode-webview://${authorityUrl}

好的,現在我們就可以將script的url source指定給我們script裡面的src屬性,為了方便起見,我們先將剛才的webview.html字串全部透過loadWebViewWithUri方法回傳,並可以傳入scriptUri參數指定

function loadWebViewWithUri(scriptUri: vscode.Uri) {
		return  `<!DOCTYPE html>
		<html lang="en">
		<head>
			<meta charset="UTF-8">
			<meta name="viewport" content="width=device-width, initial-scale=1.0">
			<title>Webview Practice</title>
		</head>
		<body>
			<h1 style="color: white;">Hello Webview!</h1>
			<img src="https://i.imgur.com/hzK0JDS.jpg" width="50%" />
			<script src="${scriptUri}"></script>
		</body>
		</html>
		`;
};

現在我們再將webview的html指定為替過過script的src屬性的字串

webviewPanel.webview.html = loadWebViewWithUri(webviewUri);

重新執行Extension,我們可以看到script順利被讀取且執行

好的,現在我們總算可以讀取外部js的檔案並執行,但讀者可能也感覺得到,這種直接傳回html字串樣板的方式並不是很美觀,被迫將html寫在js裡,其實官方網站範例都是使用這種方法載入資源的。在明天,我們會介紹community開源專案的不同載入方法,讓讀者可以有更多開發專案時的選擇。

限制使用本地資源的檔案路徑


在剛才,我們已經成功了讀取檔案。如果我們只想讓我們專案裡的某個資料夾檔案可以被extension存取呢?

為此,VSCode提供了localResourceRoots這個選項,讓我們可以限制只有某些路徑下的檔案能夠被讀取。localResourceRoots的使用示例如下:

const webviewPanel = vscode.window.createWebviewPanel(
    'webviewId', // viewType
    'WebView Title', // WebviewPanel Title
    vscode.ViewColumn.One,
    {
        ...
        localResourceRoots: [
            vscode.Uri.file(path.join(context.extensionPath, 'src/webview'))
            vscode.Uri.file(path.join(context.extensionPath, 'assets'))
        ]
    }
);

現在,若我們使用了src/disallow.js這隻檔案,內容如下

document.querySelector('h1').innerHTML = 'Disallow Javascript!';

並將其於html中載入

function loadWebViewWithUri(scriptUri: vscode.Uri, disallowScriptUri: vscode.Uri) {
    return `
        ...
        <script src="${scriptUri}"></script>
		<script src="${disallowScriptUri}"></script>
		...
    `;
}
const scriptUri = ...;

const webviewUri = webviewPanel.webview.asWebviewUri(scriptUri);

const disallowWebviewUri = webviewPanel.webview.asWebviewUri(
    vscode.Uri.parse(path.join(context.extensionPath, 'src/disallow.js'))
);

webviewPanel.webview.html = loadWebViewWithUri(
    webviewUri,
    disallowWebviewUri
);

現在我們就可以啟動extension並實際驗證disallow.js並不會被讀取與執行。

如果我們的webview完全不需要加載程式,則可以直接將localResourceRoots指定為[]的空陣列,如此一來不會有任何外部的js、style和任何圖檔可以被載入。

VSCode團隊建議我們在不需要使用資源時,可將localResourceRoots設為

    ...
    localResourceRoots: [
        vscode.Uri.file(extensionContext.extensionPath)
    ]
    ...

這樣會限制只能載入extension的路徑底下的資源。

如無其他載入資源需要,甚至就直接使用最嚴格的[]來阻止任何路徑下的檔案加載,以此保障應用程式的安全性。

在WebView內使用內容安全政策(Content-Security-Policy)


在剛才我們已經限制了讀取資源的路徑,對於瀏覽器的安全性,內容安全政策(Content-Security-Policy,簡稱為CSP)有著更近一步的設定與規範,用以限制可以被載入瀏覽器與執行的檔案,讓我們的網站更安全。

對於WebView,我們一樣可以在html下面加入meta標籤用以向瀏覽器設定CSP規則。

首先,我們在html下面插入此一meta標籤,並設定啟用CSP限制。

<meta http-equiv="Content-Security-Policy">

接著,我們就可以在meta標籤後的content屬性加上我們對於圖像、檔案、Js的載入方式與限制。

現在我們設置了第一個規則default-src,default-src可以設為self或none。當設為self時,這樣csp只會允許同一個origin的src連結,當設為none時,會擋下所有外部的連結與inline的javascript,阻止我們昨天範例那樣在html裡的script標籤下的js程式執行。

這裡我們跟官方團隊的範例一樣,先設置default-src為none。

<meta http-equiv="Content-Security-Policy" content="default-src 'none';">

阻止所有外部內容載入後,CSP允許我們以白名單的方式載入我們在default-src後面的其他設定中允許的連結。

以img的圖像載入為例,我們現在想要限制所有的img的外部連結只會允予https的協定才能載入,我們就可以在meta標籤裡這樣設定:

<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https://*;">

接著我們除了外部的連結以外,也要允許vscode自己的webviewUri能夠被瀏覽器載入,我們就可以對img-src設置第二個規則:

function loadWebViewWithUri(webview: vscode.Webview, scriptUri: vscode.Uri,...) {
    const webviewCSPSource = webview.cspSource;
    return `
        ...
        <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https://* ${webviewCSPSource}">
		...
    `;
}

這個webview.cspSource如果我們使用conosle.log查看,會是一個vscode-webview-resource:的連結。

當我們把這兩項都設定好的,即可成功的告訴WebView的瀏覽器我們只允許載入來自https或webviewUri的圖像連結。

好了,到這裡,相信讀者們已經大致了解csp設定的方式與規則,讓我們把style跟script的連結也設定CSP規則吧!

function loadWebViewWithUri(webview: vscode.Webview, scriptUri: vscode.Uri, ...) {
    const webviewCSPSource = webview.cspSource;
    return `
        ...
        <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https://* ${webviewCSPSource}; script-src ${webviewCSPSource}; style-src ${webviewCSPSource};">
		...
    `;
}

因為筆者的專案不需要cdn,因此上面的script與style就不多設定https的規則,讀者如有使用外部cdn載入檔案的需求可自行加上。

設定完這幾個規則,為了讓script的載入更加安全,讓我們在script上面加上nonce的規則吧!

  • 在CSP規則裡設定script的nonce:

nonce是什麼呢?根據維基百科中的說明:

在資訊安全中,Nonce是一個在加密通信只能使用一次的數字。在認證協定中,它往往是一個隨機或偽隨機數,以避免重送攻擊。Nonce也用於串流加密法以確保安全。如果需要使用相同的金鑰加密一個以上的訊息,就需要Nonce來確保不同的訊息與該金鑰加密的金鑰流不同。

簡言之,nonce可以是一個隨機產生的隨機字串,在每一次通訊時產生。

現在我們要在csp裡的script裡使用nonce,就需要準備一段產生nonce的函式。並以

'nonce-<base64-value>'

的格式指定我們產生的base64編碼的nonce字串給content的script-src。

相關nonce與script-src設定選項,詳見MDN網站的script-src一節。

此處我們使用VSCode官方的Webview-sample中提供的函數來產生nonce數值,如下所示:

function getNonce() {
	let text = '';
	const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
	for (let i = 0; i < 32; i++) {
		text += possible.charAt(Math.floor(Math.random() * possible.length));
	}
	return text;
}

現在有了nonce的隨機產生函數,我們即可在html的csp規則裡指定產生的nonce。除此之外,根據CSP的規範,我們也同時需要在載入連結的script標籤下設置nonce,如下所示:

function loadWebViewWithUri(webview: vscode.Webview, scriptUri: vscode.Uri,...) {
    const webviewCSPSource = webview.cspSource;
    const nonce = getNonce();
    return `<!DOCTYPE html>
    <html lang="en">
    <head>
        ...
         <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https://* ${webviewCSPSource}; script-src ${webviewCSPSource} 'nonce-${nonce}'; style-src ${webviewCSPSource};">
		...
    </head>
    <body>
    ...
    <script nonce="${nonce}" src="${scriptUri}"></script>
    ...
    </body>
</html>
    `;
}

好的,以上就是我們對於WebView CSP規則使用的講解說明。CSP的規則繁多,讀者可至MDN網站查閱更多可用的設定與相關說明。

結語


總算結束啦!

今天,我們對WebView載入外部資源與javascript有了更多了解,同時也認識了讓WebView載入資源更加安全的幾個做法。

明天我們將練習使用物件導向的方式組織WebView API,並進一步整合當前流行的SPA前端框架至WebView。我們明天見,謝謝大家。

本日參考文件



上一篇
Day16 | WebView API (二)
下一篇
Day18 | WebView API (四)
系列文
自己用的工具自己做! 30天玩轉VS Code Extension之旅36

尚未有邦友留言

立即登入留言