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?

Node の Buffer.from(s, 'base64') は * や - を黙って読み飛ばす。atob は例外にする

0
Posted at

背景

自分のサイトに Base64 の変換ツールを置いています。
ブラウザで動くので、デコードには atob を使っています。
一方、サーバー側のスクリプトでは何も考えずに Buffer.from(s, 'base64') を使っていました。
同じ文字列を渡したら同じ結果になると思い込んでいたのですが、確かめてみたらだいぶ違いました…

結論

Node.js の Buffer.from(s, 'base64') は、ゆるく読みます。

  • 標準形(+ /)と URL セーフ形(- _)を区別しない
  • Base64 に使わない文字(* など)は黙って読み飛ばす
  • = の後ろに文字が続いていても捨てる

ブラウザの atob は、URL セーフ形も不正な文字も InvalidCharacterError という例外にします。
入力が正しい Base64 かを確かめたい場面では、Buffer.from の結果だけを信じないほうがいいです。

Base64 は、バイト列を英数字と + / の64種類の文字で表す決まりだ。
3バイトを4文字にして、足りない分は末尾を = で埋める(パディング)。
URL セーフ形(Base64URL)は、URL で意味を持つ + / を - _ に置き換え、= を省いた形である。

試したこと

a?>~ という4文字をエンコードすると、標準形と URL セーフ形で見た目が変わります。
? > ~ を選んだのは、ちょうど + が出る並びだからです。

const s = 'a?>~';
console.log(Buffer.from(s).toString('base64'));    // YT8+fg==
console.log(Buffer.from(s).toString('base64url')); // YT8-fg

標準形が YT8+fg==、URL セーフ形が YT8-fg になることがわかると思います。

次に、いくつかの入力を Buffer.from と atob の両方でデコードしました。
Node.js v24.11.1 で、atob は Node のグローバルにあるものを使っています。

for (const inp of ['YT8+fg==', 'YT8-fg', 'YT8*+fg==', 'YT8+fg==ZZ']) {
  let viaAtob;
  try { viaAtob = JSON.stringify(atob(inp)); } catch (e) { viaAtob = e.name; }
  const viaBuffer = JSON.stringify(Buffer.from(inp, 'base64').toString('latin1'));
  console.log(JSON.stringify(inp), 'Buffer=', viaBuffer, 'atob=', viaAtob);
}

inp がデコードしたい文字列で、latin1 で文字にしているのはバイトをそのまま1文字ずつ見るためです(atob の戻り値と同じ形に揃えています)。
結果は下記でした。

"YT8+fg=="   Buffer= "a?>~" atob= "a?>~"
"YT8-fg"     Buffer= "a?>~" atob= InvalidCharacterError
"YT8*+fg=="  Buffer= "a?>~" atob= InvalidCharacterError
"YT8+fg==ZZ" Buffer= "a?>~" atob= InvalidCharacterError

Buffer.from は4つとも a?>~ を返しています。
* が混ざっていても、= の後ろに ZZ が付いていても、何も言わずに同じ値です。
atob は、正しい標準形の1行目以外を全部例外にしました。

どちらが困るのか

URL セーフ形をそのまま読めるのは、Buffer.from の便利なところです。
JWT(ログイン情報を URL セーフ形の Base64 で運ぶトークン)を手で読むときは、むしろ助かります。

困るのは、入力のチェックを Buffer.from に任せてしまったときです。
たとえば「送られてきた文字列が Base64 なら受け付ける」という処理で、Buffer.from が例外を出さないことを「正しい Base64 だった」と読み替えると、* 入りの壊れた文字列も通ってしまいます。
デコード結果が同じなので、ログを見ても気づけません。

逆に、ブラウザ側の私のツールは atob なので、そのままだと URL セーフ形を貼った人がエラーになります。
そこで、デコードの前に下記で標準形へ戻しています。

// URLセーフ表記を標準Base64へ戻す(-_→+/、空白を除き、パディングを補う)
function fromUrlSafe(b64) {
  let s = b64.replace(/-/g, '+').replace(/_/g, '/').replace(/\s+/g, '');
  while (s.length % 4 !== 0) s += '=';
  return s;
}
console.log(atob(fromUrlSafe('YT8-fg'))); // a?>~

b64 は利用者が貼り付けた文字列です。
- と _ を戻して4の倍数まで = を足すと、atob が読める形になります。
* のような文字はここでは消さないので、atob がちゃんと例外にしてくれます。
https://hashitosystem.com/tools/base64/

検証したいときの書き方

Node 側で厳しく見たいときは、デコードの前に文字種と長さを確かめます。

function isStrictBase64(s) {
  return /^[A-Za-z0-9+/]*={0,2}$/.test(s) && s.length % 4 === 0;
}
console.log(isStrictBase64('YT8+fg=='));   // true
console.log(isStrictBase64('YT8*+fg=='));  // false
console.log(isStrictBase64('YT8+fg==ZZ')); // false

s は検証したい文字列で、標準形だけを通す前提の正規表現です。
URL セーフ形も受けたいなら、文字クラスに -_ を足して長さの条件を外します。

Buffer.from(s, '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?