技術 約11分で読めます

iPhone 17のHEICでexifrがGPSを取れなくなる問題をブラウザ内フォールバックで救う

いけさん目次

ブラウザだけで動く軽い地図ツールを書いていて、iPhoneで撮った写真をD&Dすれば自動で地図上に並ぶ、というのは小さい個人ツールでもよくある作りだ。 ところが iPhone 17 で撮ったHEICだけ、急に全部のピンが赤道直下の (0, 0) に固まる、あるいは GPS が一切取れない、というケースが出ている。

Jacob Mei氏のDEV.to記事に、原因と「全面的に乗り換えずに直す」アプローチがまとまっていたのでメモしつつ、Web側での実務的な勘所を書き足しておく。

何が起きていたか

事象はシンプルで、iPhone 17 で撮ったHEIC写真をブラウザに食わせると、exifr(軽量で広く使われているEXIFパーサ)がメタデータを返さない。
画像自体は壊れていないし、Mac の Finder で開けば座標も日時も普通に見える。にもかかわらず、ブラウザ側の exifr だけが「読めない」と判断する。

原因は HEIC の冒頭にある ftyp box だった。

ftyp boxとブランド識別子の話

HEIC は ISO Base Media File Format(BMFF, MP4の親戚)の上に乗っている。
ファイル先頭にはどの仕様に準拠しているかを宣言する ftyp box があり、ここに「メジャーブランド」と「互換ブランド」のリストが並ぶ。「自分は HEIC ですが、ついでに HEIF や MIF1 とも互換ですよ」みたいに名乗る場所だ。

iPhone 16 までの HEIC はこの ftyp box が 44バイト 程度で済んでいた。
ところが iPhone 17 はここに MiHAheix という新しい識別子が追加され、ftyp box が 52バイト に膨らんだ。

識別子役割の概観
heicHEIC本体
mif1HEIF Image File Format
MiHAApple独自のHEIC拡張バリアント(iPhone 17から追加)
heixHEIC eXtended(より広いプロファイル)

MiHAheix 自体は Apple 独自寄りの拡張で、対応していないパーサが無視するぶんには問題ない。問題は 「ftyp の合計サイズが大きくなった」 ことそのものにあった。

exifrの「50バイト超なら諦める」ガード

exifr の HEIC 読み取りパスには、ざっくり以下のような早期リターンがある。

if (ftypLength > 50) return false;

これは「ftyp がやたら大きいファイルは HEIC ではない(or 触るのが危険)」という前提でついている、軽量パーサらしい防御的なショートカットだ。 exifr は機能を絞ることでバンドルを18KB前後に抑えていて、こういうハードコード閾値で「想定外の入力には触らない」設計になっている。

ところが iPhone 17 は 52バイト。1バイト差ではなく2バイト超過で、まさにこのガードに引っかかる。 結果としてパース全体が中断され、内側にちゃんと残っているはずの EXIF / GPS には一切手が届かなくなる。

ここでまずいのは、「HEIC として認識できない」わけではなく 「中身を見る前に拒否する」 動作なので、エラーも GPS も両方 null で返ってくることだ。フロントエンド側からは「この写真は GPS が無い写真」と区別がつかない。

「乗り換える」より「フォールバックする」

筆者(Mei氏)の判断は、exifr を捨てずに HEIC でこけたときだけ別パーサを呼ぶ というものだった。

代替候補は ExifReader。HEIC 系のブランド識別子に対して exifr よりも寛容で、52バイトの ftyp を持つ iPhone 17 HEIC からも GPS を取り出せる。 ただし ExifReader は機能が広いぶん 34KB ほどあり、メイン系の exifr (18KB) と比べると重い。すべての画像に対して常に両方積むのはバンドル的にもったいない。

そこで dynamic import を使い、「最初に exifr を試して、結果が空のときだけ ExifReader をチャンク読み込み」 という二段構えにする。

const { gps, meta } = await tryExifr(file);
if (gps !== null || meta !== null) return { gps, meta };

// exifrが何も返さなかったときだけ、重いほうを取りに行く
const [{ default: ExifReader }, buf] = await Promise.all([
  import('exifreader'),
  file.arrayBuffer(),
]);

ポイントは2つ。

1つ目は 「exifr を完全に置き換えない」 こと。 JPEG や Android 端末から来る大半の画像は今まで通り軽量パーサで処理し、初回ロード時のバンドルを膨らませない。

2つ目は Promise.all で読み込みと arrayBuffer() を並列化 している点。 ExifReader のチャンク取得(ネットワーク往復)と、ローカルの ArrayBuffer 化は完全に独立したコストなので、await を直列に書くとそのぶん遅くなる。地味だが、ユーザーがファイルを D&D してから「ピンが置かれる」までの体感に効く部分だ。

flowchart TD
    A[HEIC/JPEG ファイル] --> B[exifr で GPS 抽出を試行]
    B -->|GPS取れた| Z[マップにピン配置]
    B -->|null/空のみ| C[dynamic import: ExifReader]
    C --> D[ExifReader で再パース]
    D -->|GPS取れた| Z
    D -->|それでも null| Y[「位置情報なし」として扱う]

「軽量・正攻法のパーサ → 重いが寛容なパーサ → 諦める」というレイヤ分けは、exifr の前提を壊さずに iPhone 17 だけ救えるので、ライブラリ更新待ちのつなぎとしても素直だ。

ブラウザ完結アプリで起きやすい誤検知

GPS が取れた・取れなかった、を判定するときに、ブラウザでは特有の罠がある。 記事で挙げられている4つのハードニング策は、HEIC 関連の話というより 「クライアントだけで写真を扱うアプリの最低限の守り」 に近い。

1. Null Island を弾く

GPS 取得に失敗したパーサは、しれっと lat=0, lng=0 を返してくることがある。 これは大西洋上のいわゆる「Null Island」で、現実には誰もそこで写真を撮っていない(ガーナ沖の海上)。

判定ロジックで lat === 0 && lng === 0撮影地ではなくパーサ失敗 として扱うだけで、地図中心に大量のピンが集まる事故が消える。 個人的にはここが効いた、と書いてあるユースケースをよく見る。

2. 日時の正規表現を端アンカーする

EXIF の DateTimeOriginal を正規表現で拾うとき、/\d{4}:\d{2}:\d{2}/ のように書きがちだが、これは 「文字列のどこかに4桁:2桁:2桁が含まれていればOK」 になってしまう。 バイナリ片を文字列化したノイズの中に偶然そう見える並びがあると、ゴミを日時として採用してしまう。

// NG: 部分一致
const re = /\d{4}:\d{2}:\d{2}/;

// OK: 端アンカー
const re = /^\d{4}:\d{2}:\d{2}$/;

EXIF 値そのものをスライスして渡しているなら問題が顕在化しにくいが、文字列化されたバイトの中から拾うようなコードでは効く。

3. ファイルサイズの上限

const MAX_PHOTO_BYTES = 10 * 1024 * 1024; // 10MB

HEIC は中に複数イメージ・サムネイル・補助メタデータを抱えられるフォーマットなので、悪意あるファイルだと「異常に巨大なメタデータブロックを持つ画像」を作って、パーサ側でメモリを食い潰させることが原理的にできる。 ブラウザ単体で動くツールなので OOM はそのまま タブクラッシュ になる。

10MB は通常の iPhone 写真(多くて3〜5MB)の倍くらいに余裕を取った値で、ユーザー写真の大半は通る一方、極端なファイルは入口で落とせる。

4. BMFF の iloc box を事前検査

これがこの記事で一番面白い部分だった。 HEIC(BMFFコンテナ)の中には iloc (Item Location) という box があって、「画像の各 item が、ファイルのどのオフセットから何バイト分か」を記述している。

ここに細工をする攻撃が知られていて、たとえば次のような形がある。

パターン何が起きるか
offset = 0, length = 0 のエントリパーサがそれをファイル全体と解釈し、無限ループ・全読み込みに入る
item count を実際より大きく宣言存在しないエントリを大量に読みに行き、メモリ・時間を浪費

EXIF パーサに渡す前に iloc を覗いて、

  • offset / length に怪しいゼロが並んでいないか
  • item count がファイルサイズに対して非現実的に大きくないか

をチェックしておけば、「パーサ側のバグ次第で死ぬ」状態を一段手前で塞げる。 特にブラウザ完結アプリは、サーバー側の WAF や前段スキャナが存在しないので、この種の入力検査をフロントで自分でやる必要がある。

なぜブラウザだけで完結させたいのか

記事の前提として、TrailPaint は GPX と写真をブラウザにドロップして、地図画像を作って書き出す だけのツールで、サーバーに何もアップロードしない設計になっている。

サーバーレスの利点は機能というより姿勢の話で、

  • 写真の位置情報という、人によっては自宅・職場・行動パターンに直結する情報をサーバーに送らずに済む
  • インフラが要らないので個人で長期メンテしやすい
  • ユーザー側もアカウント不要で URL を開くだけ

といった性質がある。 そのぶん、サーバー側で「変な画像を弾く・整形する・パーサのバージョンを上げる」みたいな仕事が一切できない。 ライブラリのハードコード閾値ひとつ・新機種一つで全体が破綻するし、攻撃者にとっても「フロントの JS パーサだけが守り」になる。

iPhone 17 の ftyp 拡張のような 正常進化由来の破綻 を、フォールバック設計とフロント側の入力検査で吸収する、というのは、サーバーレス系のクライアントアプリではそろそろ前提作法と思っておいたほうがよさそうだ。

実務で見るべきところ

iPhone から来る画像を扱うブラウザ系プロダクトを抱えているなら、最低限こんな順で確認すると良い。

  1. EXIF / GPS パーサに exifr を使っているか。使っているならバージョンと、HEIC パスでの早期リターン挙動を確認
  2. iPhone 17 の HEIC(ftypMiHA / heix を含むサンプル)で GPS を取れているか実機テスト
  3. 取れていないなら、exifr のアップデート待ちか、ExifReader への dynamic import フォールバックを噛ませる
  4. ついでに Null Island 除外、日時正規表現の端アンカー、ファイルサイズ上限、iloc 事前検査を入っているか棚卸し

iPhone 18 でまた別のブランドが追加される可能性は普通にある(むしろ過去パターン的にはありそう)ので、「ハードコード閾値で判定するパーサ」を素のまま信頼しないほうがいい、というのが地味だけれど大事な学びだった。


exifr 自体は依然として軽量で素直なライブラリで、悪いのは別に exifr ではない。新フォーマット拡張に対するフロント側の構え方、つまり 正常系では軽量、異常系では重量級にフォールバック を当たり前に組み込んでおくと、個人開発のブラウザ完結ツールでも長く生き残りやすい。

iloc の “item” って何を指しているのか

BMFF/HEIC の中身は JPEG のような「1枚の画像バイト列」ではなく、複数の item が並んだコンテナになっている。
HEIC ファイルから取り出したい Exif や GPS は、そのうちの1つの item にすぎない。代表的な item はおよそ次のようなもの。

item の種類何が入っているか
プライマリ画像実際に表示される HEVC 圧縮画像
サムネイル一覧表示用の縮小画像(別 item として格納される)
Exif メタデータ撮影日時・GPS・露出情報などを含むブロック
depth map / portrait matteポートレートモードの被写界深度情報
gain mapHDR 再現のための明度補正マップ
Live Photo 参照兄弟 MOV を指すエントリなど

iloc box はこれらの item それぞれについて、「ファイル内のどのオフセットから何バイト読め」という索引を持っている。
Exif パーサから見ると、iloc に Exif item の場所を聞いてそこへジャンプし、該当バイト列だけをパースする、という流れになる。

つまり iloc を細工されると、Exif パーサは「無関係な領域を Exif として読む」「巨大な領域を全部読む」「存在しない item を探しに行く」といった挙動を誘発される。
item が複数並ぶコンテナ型フォーマット特有の攻撃面だと思っておくとよい。

ガーナ沖の海上で本当に撮った写真はどう扱うか

Null Island 除外のロジックは lat === 0 && lng === 0 を弾くわけだが、赤道と本初子午線が交わる点(ちょうどガーナ沖の大西洋上)で実際に撮った写真はどうなるのか、という話は地味に気になる。

本当に (0, 0) ぴったりで撮影するということは、まず起こらない。
GPS 座標は小数点以下数桁の精度で記録されるため、lat = 0.0, lng = 0.0 にピタリ一致するケースは実測上ほぼない。
ガーナ沖の船上で撮ったとしても、記録される値は lat = 0.000834, lng = -0.012103 のような中途半端な小数になる。

なので実務の閾値は次のどちらかで足りる。

  • 厳密に lat === 0 && lng === 0 のみを「パーサ失敗」として扱う
  • Math.abs(lat) < 1e-7 && Math.abs(lng) < 1e-7 程度の極小誤差で弾く

逆に Math.abs(lat) < 0.001 のように広めに弾くと、本当に赤道近くで撮った写真(ブラジル北部、インドネシア、ケニア、ガボン、コンゴ盆地など)まで巻き込んで消してしまう。
赤道直下は人口もそれなりにあるので、ここを雑に弾くと実害が出る。

どうしても心配なら、GPS の他フィールド(GPSVersionID, GPSTimeStamp, GPSAltitude など)が揃っているかを併用判定すると確度が上がる。
パーサが壊れて失敗しているときは、lat/lng だけでなく他のフィールドも一斉に欠落していることが多いので、この差で「座標は (0,0) っぽいが他フィールドは健全」なら本物、「座標も他フィールドも全部 null」ならパーサ失敗、と切り分けられる。

exifr をフォークして閾値を上げなかった理由

ftyp > 505064128 に書き換えたフォークで済ませる、という選択肢はあり得た。ほかの場面ではよく使う手でもある。
それでも dynamic import フォールバックを選んでいるのは、それなりに理由がある。

1つ目は追従コスト。
exifr は現役でメンテされているライブラリなので、フォークした瞬間から upstream のバグ修正・新機種対応・セキュリティパッチを自分で取り込み続ける義務が生まれる。
個人ツールの 1 行パッチのためにこの運用を抱えるのは割に合わない。

2つ目は、原因が閾値 1 個だけとは限らない点。
Apple が追加するブランド識別子が MiHA だけで止まる保証はなく、将来の iPhone 世代で別の識別子や box 構造の変更が入ってくることは普通にあり得る。
閾値だけ上げても、次に必要になるのは別ガードの緩和や別ブランドの追加対応かもしれない。その都度フォークを育てるより、最初から寛容な別実装(ExifReader)に逃がすほうが先が読める。

3つ目は撤退のしやすさ。
dynamic import で切り分けておけば、exifr の upstream が iPhone 17 問題を修正したタイミングで、フォールバック経路ごと削るだけで素の構成に戻せる。
フォークは一度作ると「もう要らないのに参照が残っていて消せない」という状態になりやすいので、戻せる構造で繋いでおくほうが上流復帰の選択肢を失わない。

4つ目は、軽量パーサの哲学を勝手に壊したくない、という判断。
exifr の ftyp > 50 制限は「想定外の入力には触らない」という防衛的な前提の上にあるガードで、ここを自前で緩めるフォークは、本当に壊れた HEIC を食わされたときのリスクを自分で引き受けることを意味する。
dynamic import でパーサごと取り替える構造なら、判断主体は ExifReader 側に委ねられ、exifr の想定は保ったままにできる。

ハードコード閾値をフォークで緩めるのは「速いがロックインされる」、dynamic import フォールバックは「最初の構えが少し面倒だが逃げ道が広い」。
長期運用するブラウザ完結アプリでは、後者のほうが相性がいい、というトレードオフだった。