webhintのヒントを自作する
UI開発者 加藤この記事はミツエーリンクス Advent Calendar 2019 - Qiitaの1日目の記事です。
ミツエーリンクスは今年初めてアドベントカレンダーに参加します!フロントエンドだけでなく、アクセシビリティやWebサイトの品質に関する記事もアップされる予定なので、是非チェックしてみてください!
先日webhintを活用しようという記事でwebhintの使い方を説明しましたが、今回は「ドメインをまたぐリクエストの場合、CookieにSameSite
属性とSecure
属性がついているかチェックする」をテーマにwebhintのヒントを自作してみたいと思います。
2020年2月にリリース予定のGoogle Chrome 80からCross-siteなリソースに関連付けられたCookieは明示的にSameSite=None;
とSecure
属性が付与されていないと送信されなくなります。FirefoxやEdgeでも同様に実装されるようなのでCookieに必要な属性が付与されているかどうかをwebhintにチェックしてもらいましょう!
※「新しい Cookie 設定 SameSite=None; Secure の準備を始めましょう」も参照ください。
新しいヒントの初期化
新しくヒントを自作する際はcreate-hintというイニシャライザーを利用できます。適当なフォルダで
npm init hint
を実行するといくつかの質問が繰り返され、回答をもとにベースとなるファイルが作成されます。選択したカテゴリによってチェックを行うタイミングが変わったりしますが、あとで追加・変更することも可能なのであまり意識しなくても大丈夫です。今回は以下のように設定しました。
質問 | 回答 |
---|---|
ヒントの名前 | cross-site-cookie |
ヒントの説明 | Cross-siteなCookieにSameSite=None とSecure 属性が指定されているかをチェックします |
カテゴリ | security |
ユースケース | Resource Request |
ヒントのスコープ | site |
4つ目のユースケースには以下の4つの選択肢があります。
DOM
: ヒントが特定のDOM要素のチェックを行うケースResource Request
: 画像やスクリプトのようなリソースのローディングに関連するケースThird Party Service
: サードパーティのサービスと統合するケースJavaScript Injection
: サイトをチェックするのに別途JavaScriptコードを挿入する必要があるケース
イニシャライザーの処理が終わると、いくつかのファイルとフォルダが作成されています。まずは必要なパッケージをインストールするために
npm run init
を実行しましょう。本記事を公開した時点ではhintと@types/requestがないことでエラーになってしまったため、別途インストールしました。
ヒントクラスの基本
ヒントのチェックロジックはsrc/hint.ts
に書いていきます。まずはこのhint.ts
の中身を基本的な箇所だけ抜粋して見てみます。
import { HintContext } from 'hint/dist/src/lib/hint-context';
import { IHint, FetchEnd } from 'hint/dist/src/lib/types';
export default class CrossSiteCookieHint implements IHint {
public constructor(context: HintContext) {
const validateFetchEnd = (fetchEnd: FetchEnd) => {
const { resource } = fetchEnd;
if (Math.ceil(Math.random()) === 0) {
context.report(resource, 'Add error message here.');
}
};
context.on('fetch::end::*', validateFetchEnd);
}
}
新しく作るヒントクラスはIHint
というインターフェースを実装する必要があります。ポイントとなるのは
context.on('fetch::end::*', validateFetchEnd);
の部分です。jsdomやPuppeteerなどのコネクタはこのイベントを通して、各ヒントクラスに情報を渡します。ここで指定されているfetch::end::*
というのは、サイトを開いたあと各リソースの読み込みが完了するたびに発火するイベントです。イベントの詳細はEventsページで参照できます。
また、
context.report(resource, 'Add error message here.');
の一文が実行されると、このヒントには通らなかったこととなり最終的に出力される結果レポートでエラーとして報告されます。一度if
文の外にこの一文を出して毎回エラーになるかを試してみましょう。まずは、TypeScriptファイルをビルドします。
npm run build
ビルドが成功するとdist
フォルダが作成されその中にJavaScriptファイルが出力されているはずです。つづいて、今回新規で作成した「cross-site-cookie」によるチェックが走るように.hintrc
を調整します。
{
"connector": {
"name": "jsdom",
"options": {
"waitFor": 1000
}
},
"formatters": ["html"],
"hints": {
"cross-site-cookie": "error"
},
"hintsTimeout": 120000
}
ここまでできたらあとはwebhintを実行するだけです。
npx hint https://www.example.com
結果、以下のキャプチャのようなHTMLが吐き出されました。
新しく作成した「cross-site-cookie」というヒントが「SECURITY」カテゴリに分類され、すべてのリクエストがエラーとして報告されているのが分かります!
チェックのロジックを作る
さて、べースはでき上がったので実際にチェックするロジックを書いていきましょう。
fetch::end::*
イベントのコールバック関数(validateFetchEnd
)にはFetchEnd
オブジェクトが引数として渡されますが、このオブジェクトはresource
、request
、response
プロパティを持っており、その中のresponse
からCookieの情報を抜き取ります。Cookieのパース処理はCookieを利用しました。チェックの流れは以下の通りです。
ページのドメインとCookieのドメインが同じかどうか(つまりCross-siteかどうか)
Cross-siteの場合
SameSite
属性に「None」が指定されているかどうか- Secure属性が指定されているかどうか
シンプルに実装すると、
const validateFetchEnd = async (fetchEnd: FetchEnd) => {
const { resource, response } = fetchEnd;
const cookieList: string | string[] = response.headers && response.headers['set-cookie'] || '';
if (!cookieList) {
return;
}
for (let i = 0, len = cookieList.length; i < len; i++) {
const cookie = cookieParser.parse(cookieList[i]);
const domain = cookie.Domain || cookie.domain;
// ドメインが同じならチェックしない
if (domain && domain === {SITE_DOMAIN}) {
return;
}
// Cross-siteでかつ、SameSite属性がない場合もしくは「none」に設定されていない
if (!cookie.SameSite || cookie.SameSite !== 'None') {
context.report(resource, `A cookie associated with a cross-site resource at 「${domain}」 was set without the 'SameSite' attribute`);
return;
}
// Cross-siteでかつ、Secure属性が指定されていない
if (!cookie.Secure) {
context.report(resource, `A cookie associated with a resource at 「${domain}」 was set with 'SameSite=None' but without 'Secure'.`);
return;
}
}
};
のようになります。TypeScriptをビルドし直してから、特定のサイトで実行してみます。
Cross-siteなCookieのうち必要な属性が付与されていないものが警告されてますね!これで新しいヒントの作成はおしまいです。webhintの公式ページでは「ページにコピーライトがあるかどうかをチェックするヒントのサンプル」が紹介されていますので、興味がある方は参考にしてみてください。