おそらくはそれさえも平凡な日々

Cloudflare PagesにそれなりにちゃんとBasic認証をかける

前回の、社内プライベートポッドキャスト実現方法で、ポッドキャストサイトを静的配信しつつBasic認証をかけるというアイデアを書いた。しかし、Basic認証などなかなか使わなくなり、ネイティブでサポートしている静的ホスティングサービスも少ない。今回はCloudflare PagesのFunctions機能でリクエストをラップするミドルウェアを書けば実現できることが分かり、その方式を採用することにした。多少実装必要になるのと、認証周りを自前で書くのはあまりやりたくはないが、廉価に比較的省力で実現できるので受け入れる。

ネット上にいくつかサンプルは見つかるが、今回実装するにあたっては以下の点を留意した。

  • コード内に認証情報を載せない
  • 複数ユーザーのIDとパスワードを管理できるようにする
  • パスワードは定数時間比較してタイミング攻撃を防ぐ

これらを以下のように解決することとした。

  • 認証情報は環境変数に秘匿化された値として保存する
  • PASSWORD_ プレフィクスがついた環境変数を認証情報とし、そのサフィックスをユーザー名とする
    • 例: PASSWORD_FOO という環境変数が fooユーザーのパスワードとなる
  • 定数時間比較はCloudflareが用意してくれている(!) timingSafeEqual が使えて便利

ちなみに、定数時間比較については、サボって普通の文字列比較をしていたら、同僚が指摘をしてくれた。持つべきものは優秀な同僚である。やはりこういう処理は自分では書きたくないですね。定数時間比較は、Node.jsにもcrypto.timingSafeEqual が標準搭載なので便利な世の中になった。ところで、文字列の定数時間比較については10年前にも元同僚が、GitHubのWebhookの署名文字列の検証関連で指摘してくれたことも思い出したので昔から周囲に恵まれている。

ということで、作ったものが以下。これをプロジェクトリポジトリにfunctions/_middleware.ts として配置し、Cloudflare Pagesにデプロイすれば、Basic認証をかけてくれる。便利。これは、podbardのプライベートポッドキャストのテンプレートリポジトリでも公開している ものと同じなので、何か問題があれば指摘してもらえると嬉しいです。

// Created by Masayuki Matsuki (Songmu) on 2024-09-11
// Copyright © 2024 Masayuki Matsuki. Licensed under MIT.
//
// ref. https://developers.cloudflare.com/pages/functions/middleware/
// ref. https://developers.cloudflare.com/pages/functions/api-reference/#eventcontext
type EventContext = {
  request: Request;
  next: (input?: Request | string, init?: RequestInit) => Promise<Response>;
  env: {
    ASSETS: object;
    CF_PAGES: string;
    CF_PAGES_BRANCH: string;
    CF_PAGES_COMMIT_SHA: string;
    CF_PAGES_URL: string;
    [key: string]: any;
  };
};

type Middleware = (context: EventContext) => Promise<Response>;

type Middlewares = Middleware | Middleware[];

const errorHandler: Middleware = async ({ next }: EventContext):
  try {
    return await next();
  } catch (err: unknown) {
    console.log(`Error: ${err.message}\n${err.stack}`);
    return new Response("Internal Server Error. Please contact the admin", { status: 500 });
  }
};

const passEnvPrefix = "PASSWORD_";

const basicAuth: Middleware = async ({ request, next, env }: EventContext): Promise<Response> => {
  if (!request.headers.has("Authorization")) {
    return new Response("You need to login.", {
      status: 401,
      headers: {
        "WWW-Authenticate": 'Basic realm="Input username and password"',
      },
    });
  }
  const authorizationHeader = request.headers.get("Authorization");
  if (!authorizationHeader) {
    return new Response("Authorization header is missing.", {
      status: 400,
    });
  }

  const [scheme, encoded] = authorizationHeader.split(" ");
  if (!encoded || scheme !== "Basic") {
    return new Response("Malformed authorization header.", {
      status: 400,
    });
  }

  const buffer = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
  const decoded = new TextDecoder().decode(buffer).normalize();
  const index = decoded.indexOf(":");

  if (index === -1 || /[\0-\x1F\x7F]/.test(decoded)) {
    return new Response("Invalid authorization value.", {
      status: 400,
    });
  }

  const username = decoded.substring(0, index);
  const password = decoded.substring(index + 1);
  if (password === "") {
    return new Response("Password is empty.", {
      status: 401,
    });
  }

  const key = passEnvPrefix + username.toUpperCase();
  const storedPassword = env[key];
  const userExists = typeof storedPassword === "string";
  // Even if the user does not exist, a constant time comparison with a dummy string is performed
  // to prevent timing attacks.
  const matchPassword = await compareStrings(password, userExists ? storedPassword : "dummy");
  if (!userExists || !matchPassword) {
    return new Response("Invalid username or password.", {
      status: 401,
    });
  }

  return await next();
};

async function sha256(str: string): Promise<ArrayBuffer> {
  const encoder = new TextEncoder();
  const data = encoder.encode(str);
  return crypto.subtle.digest("SHA-256", data);
}

async function compareStrings(a: string, b: string): Promise<boolean> {
  const hashA = await sha256(a);
  const hashB = await sha256(b);

  // The buffer lengths must be the same, and they were aligned to the same length by hashing,
  // but we are checking to be sure because timeSafeEqual will throw an exception if the lengths
  // are different.
  // ref. https://developers.cloudflare.com/workers/examples/protect-against-timing-attacks/
  if (hashA.byteLength !== hashB.byteLength) {
    return false;
  }
  // ref. https://developers.cloudflare.com/workers/examples/protect-against-timing-attacks/
  return crypto.subtle.timingSafeEqual(hashA, hashB);
}

export const onRequest: Middlewares = [errorHandler, basicAuth];
created at
last modified at

2024-09-14T14:49:47+0900

comments powered by Disqus