之前工作上主要是用 AWS,AWS 放靜態網站有過 CloudFront CDN 時,如果需要調整 header 的話,官方的解決方案是用 Lambda@Edge,寫 AWS Lambda function 的時候,其實我個人有一個偏好,就是能不用第三方 module 就不用,主要原因有兩個,第一個原因是,如果程式碼太大包,會無法在 AWS console 上直接看(或修改)程式碼;第二個原因是發佈流程會比較麻煩,因為還要去安裝 module,然後再全部打包起來上傳。
要調整 header 的一個主要原因就是為了 security headers,大部分的 security header 都還算單純,但是 CSP(Content Security Policy)就複雜很多了,如果沒有用結構化的資料,其實很難維護,但是針對 Lambda function 我又不想要用第三方 module,最後我想到的解決方案,就是設計一個很簡短的工具函式來把結構化的資料轉成 CSP header 的值,這就是我最近趁 COSCUP 2021 會議期間整理好的新的 open source 專案:CSP。
這個專案內容就只是一個簡單的 function:
const CSP = (directives) => {
return directives
.map((directive) => {
return `${directive.name} ${directive.value.join(' ')};`;
})
.join(' ');
};
不過為了好好設計這個 function 其實我也是花不少功夫,首先就是輸入參數的結構要長怎樣,其實一般比較常見的是用物件 property 直接就作為 directive name 的形式,像是 Google 的 CSP Evaluator:
{
"default-src": ["'none'"],
"script-src": ["'self'"],
"connect-src": ["blah", "blah"]
}
這種結構比較精簡,但是問題就是無法保證順序,考慮再三之後,決定還是用陣列的形式:
[
{
"name": "default-src",
"value": ["'none'"]
},
{
"name": "script-src"",
"value": ["'self'"]
}
]
這樣就可以讓開發人員確保輸出的順序,其實大部分時候我也不會那麼在意順序,不過要是default-src
如果不是第一個感覺就很不舒服。確定主要的資料結構後,再來就是屬性名稱要用什麼好的問題了,為了找到正確的名稱,我去翻了 CSP spec 找到關於 parsing 相關的說明,確定了 spec 定義的結構是這樣的(使用 TypeScript 語法):
type Source = string;
type Directive = {
name: string;
value: Source[];
};
type Policy = {
source: "header" | "meta";
disposition: "enforce" | "report";
directiveSet: OrderedSet<Directive>;
};
type Policies = Policy[];
在輸入資料的陣列中,每個元素都是Directive
,Directive
的兩個屬性分別是name
和value
,value
則是Source
的陣列集合,當然Source
還有更嚴謹的定義,不過這邊就簡化成字串就好。確定完輸入資料的結構後,就是要想盡辦法簡化 function 的內容了,但是也不希望太難讀懂,調整了幾次變成現在的樣子,我還提供了精簡的版本:
const CSP = p => p.map(d => `${d.name} ${d.value.join(' ')};`).join(' ');
其實我對於那個map
接join
一直耿耿於懷,很想要用reduce
解決,但是要避免頭尾多空白,會需要多判斷式,就算不予理會,程式碼長度其實還是比現在這個版本長,結果還是map
接join
看起來比較漂亮,所以最後的版本就維持這樣了。
然後我還寫了測試和提供了兩個 example,分別是 Lambda@Edge 和 Cloudflare Workers 的,不確定還有沒有類似的服務,如果有發現會再加上。最後就是,因為這個 function 設計就是要給人複製貼上的,所以並沒有發布到 npm 上,然後使用 MIT-0 license 所以也不用 attribution,覺得有興趣使用的就請直接複製貼上吧~
PS. 如果有其他需求,可以看看 csp-header,例如 Express 使用,我覺得介面設計得很不錯。