0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Basic認証のヘッダーを btoa で作ったら、田中は例外になり、café は黙って別の値になった

0
Posted at

背景

自分のツールサイトに、ユーザー名とパスワードから Basic認証のヘッダーを作るページを置いています。
https://hashitosystem.com/tools/basicauth/
中身を読み返していたら、Base64 にする部分が btoa(user + ':' + pass) の1行ではなく、TextEncoder を通す8行の関数になっていました。
なぜ遠回りしているのか、自分でも忘れていたので、1行版に戻すと何が起きるか試しました(理由をコメントに書かないのは本当に悪い癖です…)。

Basic認証のヘッダーの形

前提です。
Basic認証は、HTTP のリクエストにユーザー名とパスワードを載せて認証する、いちばん単純な方式だ。
仕様は RFC 7617 で、ユーザー名:パスワード をコロンでつないだ文字列を Base64 にして、Authorization: Basic {Base64の文字列} というヘッダーで送ります。
Base64 は、任意のバイト列を英数字と + / = だけの文字列に置き換える符号化で、暗号化ではありません(誰でも元に戻せます)。

ASCII だけなら btoa 1行で作れます。

console.log(btoa('tanaka:p@ss')); // dGFuYWthOnBAc3M=

1行版に日本語を入れる

btoa はブラウザにも Node.js にもある Base64 化の関数で、引数の文字列を「1文字=1バイト」とみなして変換する。
なので 0〜255 に収まらない文字が来ると変換できません。
下記は Node.js 24 で、ユーザー名を「田中」にしたものです。

try {
  btoa('田中:p@ss');
} catch (e) {
  console.log(e.name, e.message); // InvalidCharacterError Invalid character
}

例外で落ちるので、こちらはまだ気付けます。
厄介なのは、0〜255 に収まる文字のほうでした。

console.log(btoa('café:x')); // Y2Fm6Tp4

é は Latin-1(1バイトで西欧の文字を表す文字コード)の範囲にあるので、例外は出ずに 0xE9 の1バイトとして変換されます。
ところが UTF-8 では é は 0xC3 0xA9 の2バイトなので、サーバーが UTF-8 で照合していると、この値は別人のパスワードになる。
エラーは出ないのに認証だけが通らない、という一番いやな壊れ方です。

UTF-8 のバイト列にしてから渡す

ツールで使っているのが下記です。
str には ユーザー名 + ':' + パスワード をつないだ文字列を渡します。

function b64encodeUtf8(str) {
  const bytes = new TextEncoder().encode(str); // UTF-8 のバイト列
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b); // 1バイト1文字の文字列に
  return btoa(bin);
}

console.log(b64encodeUtf8('田中:p@ss')); // 55Sw5LitOnBAc3M=
console.log(b64encodeUtf8('café:x'));    // Y2Fmw6k6eA==

TextEncoder で一度 UTF-8 のバイト列にし、各バイトを 0〜255 の1文字に置き直してから btoa に渡している。
田中:p@ss は Node.js の Buffer.from('田中:p@ss', 'utf8').toString('base64') と同じ 55Sw5LitOnBAc3M= になりました。
café も、1行版の Y2Fm6Tp4 から Y2Fmw6k6eA== に変わっています。
並べると下記のとおりです。

入力         btoa だけ                    TextEncoder 経由
tanaka:p@ss  dGFuYWthOnBAc3M=             dGFuYWthOnBAc3M=  (ASCII は同じ)
田中:p@ss    InvalidCharacterError        55Sw5LitOnBAc3M=
café:x       Y2Fm6Tp4 (Latin-1 の値)      Y2Fmw6k6eA==      (UTF-8 の値)

なぜ UTF-8 に揃えるかというと、RFC 7617 がサーバー側に charset="UTF-8" を宣言する手段を用意していて、実質の標準がそちらだからです。
ただし古いサーバーには Latin-1 前提のものもあるらしく、そこは相手次第になります。

ユーザー名のコロンは弾く

もう1つ、ツールにはユーザー名にコロンがあると生成を止める分岐が入っています。
RFC 7617 では、ユーザー名にコロンを含めてはいけない、と決まっている。
受け取る側は最初のコロンで分割するので、試すと下記のようになりました。

const s = 'dev:team' + ':' + 'secret';
const i = s.indexOf(':');
console.log([s.slice(0, i), s.slice(i + 1)]); // [ 'dev', 'team:secret' ]

ユーザー名 dev:team のつもりが、ユーザー名 dev・パスワード team:secret として読まれます。
逆にパスワード側のコロンは問題なく、user:a:b:c はパスワード a:b:c として正しく戻りました。

Basic認証のヘッダーを JavaScript で作るなら、btoa に直接渡さず TextEncoder で UTF-8 にしてから Base64 にし、ユーザー名にコロンがあれば作る前に止めてください。


本記事はAI補助で執筆した、個人開発の紹介記事です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?