😈

ファイルアップロードではNFC/NFD問題に気をつけろ!~MacファイルシステムにおけるUnicode正規化の闇~

2023/08/29に公開

NFC/NFD問題とは?

みなさん、NFC/NFD問題はご存じでしょうか?
NFC/NFD問題とはUnicode正規化形式の違いにより、発生するさまざまな問題のことを指します。Unicodeでは見た目が同じでもバイト列が異なる表現が可能で、異なる表現に変換することをUnicode正規化と呼びます。

例えば、「パ」はUnicodeでU+30D1と表されますが、こちらの「パ」をNFCとNFDそれぞれの正規化形式で正規化を行うと、以下のようになります。

正規化形式 正規化後のUnicode
NFC (パU+30D1)
NFD (ハU+30CF)+ (゜U+309A)

NFCではそのまま「パ」として表されますが、NFDでは「ハ」(基底文字)と「゜」(結合文字)の組み合わせとしての「パ(UTF-8でe3 83 8f e3 82 9a)」(合成文字)で表されます。試しにNFDで正規化された「パ(e3 83 8f e3 82 9a)」を任意のテキストエリアに貼り付けて削除してみると、半濁音のみが取れて「ハ」のみになると思います。

パを削除するGIF

このように文字列の正規化形式が異なる場合、単純な比較演算子での評価は困難であり、文字列によっては想定外の挙動を引き起こす可能性があります。

JavaScriptの場合
console.log("パ" == "パ") // -> false

特にMacファイルシステムではNFDを正規化方式と採用しているため、NFC/NFD問題が度々引き起こされています。先日(2023年03月27日)リリースされた「macOS 13.3 Ventura (22E252)」では、ファイル名に濁音や半濁音が含まれるファイルがFinderから開けなくなるというバグが報告されています。詳細は以下を参照してください。
https://applech2.com/archives/20230402-nfd-and-nfc-issues-in-macos-13-3-ventura.html

OSX標準ツールのFinderでさえ対策が漏れてしまうほど、NFC/NFD問題は非常に見落とされやすい問題です。しかし、この問題を見落としてしまうとFinderの例のように、影響範囲の大きいインシデントを引き起こす可能性があります。そのため、私たちアプリケーション開発者はこの問題を強く意識し対策を施しておく必要があります。

Mac版ChromeにおけるNFC/NFD問題

残念ながらChromeからのファイルアップロード<input type="file"/>ではNFC/NFD問題への対応がなされていません。そのため、ファイル名が同一であってもファイル名に濁音や半濁音文字が含まれる場合、Mac(NFD方式)からアップロードされたファイルとWindows(NFC方式)からアップロードされたファイルではファイル名が同一と判断されなくなります。

そのためファイル名をDBに保存しているようなアプリケーションでは、前述した理由からファイル名でのテーブル検索が困難になります。

PostgreSQL v15.4の場合
$ select * from files;
 name
------
 パ # MacからUploadされたファイル(`e3 83 8f e3 82 9a`)
(1 rows)

# ユーザは文字コードの違いにより指定したファイルが検索できない。
$ select * from files where name='パ'; # 手入力すると文字コードは`e3 83 91`
 name
------
(0 row)

対処方法

アプリケーション側でNFDをNFCへ変換する、もしくはNFCをNFDへ変換する処理を施すことにより、文字コードの揺らぎを統一することができます。
WindowsやLinuxなど、Mac以外のファイルシステムではNFC方式を採用していることが多いため、基本的にNFCへ変換する方針をとるケースが多いです。

実装例

JavaScriptではString.prototype.normalize()メソッドが用意されているので簡単に変換することが可能です。

JavaScriptの場合
const nfcString = "パ"
const nfdString = "パ"

console.log(nfcString == nfdString) // -> false

const convertNfd2Nfc = nfdString.normalize("NFC")

console.log(nfcString == convertNfd2Nfc) // -> true

Rubyにも同様のString#unicode_normalizeメソッドが用意されています。

Rubyの場合
nfcString = "パ"
nfdString = "パ"

puts nfcString == nfdString # -> false

convertNfd2Nfc = nfdString.unicode_normalize(:nfc)

puts nfcString == convertNfd2Nfc # -> true

Golangではgolang.org/x/text/unicode/normパッケージに用意されています。

Golangの場合
package main

import (
	"fmt"

	"golang.org/x/text/unicode/norm"
)

func main() {
	nfcString := "パ"
	nfdString := "パ"

	fmt.Println(nfcString == nfdString) // -> false

	convertNfd2Nfc := norm.NFC.String(nfdString)

	fmt.Println(nfcString == convertNfd2Nfc) // -> true
}

まとめ

NFC/NFD問題は合成文字が存在する言語圏にしか起こり得ない問題のため、世界的に問題視される機会は少なく、NFC/NFD問題について初めて耳にする方も多いと思います。
しかし、今回紹介したように実装する機会の多い検索機能にも大きく影響を与える問題のため、日本語ファイルを取り扱う際には注意が必要です。

参考文献

Internet Week 2010: 文字コードに潜むセキュリティ

Hacobell Developers Blog

Discussion