⚙️

Web フロントで Authorization Header が必要な画像を表示する2つの方法

2023/08/26に公開

概要

Web フロントエンドにおいて、HTTP GET で認証が必要な画像を img 要素で表示したいケースについて考えます。

認証に Cookie を使う場合、ブラウザが自動で Cookie を付与したリクエストを発行するため問題なく取得できます。

認証に Authorization Header もしくは独自の X-Service-Token のような Request Header を使う場合、img 要素では自動で任意の Header は付与してくれない為、認証を通すために何かしらの手段を講じる必要があります。

ここでは、Authorization Header を指定する必要がある画像の取得において、取り得る手段を検討します。

対象読者

TL;DR

Service Worker もしくは Web Components を利用することでこの問題の解決を試みます。

  • Service Worker を用いてリクエストを中継し、Authorization Header を挿入する
  • Web Components を利用して img 要素を拡張し、Authorization Header を付与した fetch を実行する

双方に良し悪しがあり、どちらが優れているとは言えません。その都度要件に合った方法で解決しましょう。

モチベーション

Query Parameter に api-token=xxx のように API Token を含める場合、URL に credential が含まれることになります。特に対策を打たない限りミドルウェアのログや、Web サーバーのアクセスログに記録されてしまいます。このログを閲覧することができる開発者は、リクエストしてきたユーザーに成りすますことができるというリスクがあります。

URL から credential を確実に取り除いてログ出力するというのは大変手間です。ミドルウェア等の構成変更のタイミングで対応が漏れて、再びログに出力されるリスクもあります。

そこで、署名付き URL を用いるという手があります。これは、その操作やリソースに対して短時間有効な credential を発行して URL に付与するため、credential の悪用によるリスクを最小限に抑えることが可能です。操作が限定されていない、有効期間の長い API Token を URL に含めるよりはいくらかマシでしょう。要件によってはこのような手段を取る必要があります。

一方、Authorization Header であれば、意図的に出力されるような設定にしない限りログに現れることはありません。
更に、画像配信サーバーで Web フロントエンドのためだけに URL に含まれる credential を処理する必要が無くなり、実装の保守性が上がります。

セキュリティと保守性の観点から、Query Parameter に credential を含めるのは基本的に避けた方が良いでしょう。

解決方法

Service Worker を使う方法

Service Worker を使うと、ページ上で発火する FetchEvent を中継することができます。

JavaScript (Service Worker)
const interceptor = async (request, token) => {
    const headers = new Headers(request.headers);
    headers.set("Authorization", `Bearer ${await token}`);
    const req = new Request(request, {
        mode: "cors", // no-cors mode では Request Header が書き換えられないため変更
        headers
    });
    return await fetch(req);
};

self.addEventListener("fetch", event => {
    const req = event.request;
    if (
        req.method !== "GET" ||
        req.headers.get("Authorization") != null ||
        !req.url.includes("/restricted/")) {
        // do nothing
        return;
    }
    event.respondWith(interceptor(event.request, store.getToken(3000)));
});
HTML
<img src="path/to/restricted/image.jpg"></img>

利点

  • img タグの記述を修正する必要がない
  • CSS の background-image で URL を指定する場合などにも適用可

欠点

  • Service Worker のインストールの直後と hard reload の直後はリクエストが中継されず、画像が表示されない

Service Worker のインストール直後に関しては、一度 HTTP Request が失敗することを許容できれば imgonerror 属性を使って retry することができます。

JavaScript
function waitServiceWorkerActivated() {
    // Service Worker が activated になると resolve される Promise を返す
    return new Promise((resolve) => {
        const onStatechange = (event) => {
            if (navigator.serviceWorker.controller.state === "activated") {
                if (event != null) {
                    navigator.serviceWorker.controller.removeEventListener("statechange", onStatechange);
                }
                resolve(navigator.serviceWorker.controller);
                return true;
            }
            return false;
        };
        const onControllerchange = (event) => {
            if (navigator.serviceWorker.controller) {
                if (event != null) {
                    navigator.serviceWorker.removeEventListener("controllerchange", onControllerchange);
                }
                if (onStatechange()) {
                    return true;
                }
                navigator.serviceWorker.controller.addEventListener("statechange", onStatechange);
                return true;
            }
            return false;
        };
        if (onControllerchange()) {
            return;
        }
        navigator.serviceWorker.addEventListener("controllerchange", onControllerchange);
    });
}

function retry(target) {
    // 1度きりの retry を実現するために flagment を付与
    const retryFlag = "#retry";
    if (target.src.includes(retryFlag)) return;
    waitServiceWorkerActivated().then(() => {
        target.src += retryFlag;
    });
}
HTML
<img onerror="retry(this)" src="path/to/restricted/image.jpg"></img>

Web Components を使う方法

Web Components により HTMLImageElement を拡張します。Fetch API により Authorization Header を付与したリクエストを行い、取得した画像の Object URL 生成して img 要素の src 属性に設定することで、画像を表示します。

JavaScript
class RestrictedImage extends HTMLImageElement {
    constructor() {
        super();
        const url = this.getAttribute("restricted-src");
        const self = this;
        fetch(url, {
            mode: "cors",
            credentials: "include",
            headers: {
                Authorization: `Bearer ${token}`
            }
        }).then(resp => resp.blob()).then(blob => {
            self.src = URL.createObjectURL(blob);
        }).catch(() => {
            console.error("failed to fetch in RestrictedImage");
        });
    }
}
customElements.define("restricted-image", RestrictedImage, { extends: "img" });
HTML
<img restricted-src="path/to/restricted/image.jpg" is="restricted-image"></img>

組み込み要素の拡張 (is 属性) に対応していない Safari ではこの方法は動かないため、Safari でも動くパターンも用意しました。

JavaScript
class RestrictedImageElement extends HTMLElement {
    constructor() {
        super();
        const shadow = this.attachShadow({ mode: "open" });
        const img = new Image();
        const url = this.getAttribute("src");
        const self = this;
        this.getAttributeNames().forEach(key => {
            if (key === "src") return;
            img[key] = self.getAttribute(key);
        });
        fetch(url, {
            mode: "cors",
            credentials: "include",
            headers: {
                Authorization: `Bearer ${token}`
            }
        }).then(resp => resp.blob()).then(blob => {
            img.src = URL.createObjectURL(blob);
        }).catch(() => {
            console.error("failed to fetch in RestrictedImageElement");
        });
        shadow.appendChild(img);
    }
}
customElements.define("restricted-img", RestrictedImageElement);
HTML
<restricted-img src="path/to/restricted/image.jpg"></restricted-img>

利点

  • hard reload の直後でも画像が表示される

欠点

  • img タグの記述を修正する必要がある

Demo

実際に動くページを用意しました。

https://fetch.ww24.info/

まとめ

操作やリソースが制限されていない有効期間の長い API Token を URL に含めるのはアンチパターンでリスクが高いので、

  • Service Worker を用いてリクエストを中継し、Authorization Header を挿入する
  • Web Components を利用して img 要素を拡張し、Authorization Header を付与した fetch を実行する

これらが難しい場合は、最終手段として(操作やリソースが制限され有効期間の短い)署名付き URL を使用する事を検討するのが良いでしょう。

ここ何年も Web フロントエンドから離れていたので久々に触りましたが、便利になっていますね。

因みに Service Worker で Authorization Header を付与するというのは Firebase Auth が結構前からやっているようです。(Beta ですが)

Discussion