再代入禁止の先にあるもの:`const`でオブジェクトを定義した際の「不変性」の誤解を解く
JavaScriptのランタイム、とりわけV8エンジンの内部構造とメモリモデルを極限まで最適化するアーキテクトとして、日々のコードレビューやセキュリティ監査で最も頻繁に遭遇する誤謬の一つが、`const`キーワードに対する過度な信頼である。
多くの開発者は、`const`を使ってオブジェクトを宣言した瞬間、そのデータ構造全体が「イミュータブル(不変)」になると誤認している。しかし、V8のヒープメモリ空間や変数のバインディングメカニズムを覗けば、この認識がいかに表層的なものであるかが一目瞭然となる。
本稿では、`const`の本当の役割をレキシカル環境(Lexical Environment)とメモリポインタの観点から再定義し、`Object.freeze`との決定的な違い、そしてこの誤解が招くプロトタイプ汚染(Prototype Pollution)とリモートコード実行(RCE)の脆弱性への扉について、V8の内部挙動を交えて徹底的に解説する。
—
1. `const`の本質:不変性(Immutability)ではなく「定数バインディング」
まず大前提として、ECMAScript仕様書およびV8エンジンの実行モデルにおいて、`const`は「値の不変性」を保証するものではない。それは「変数識別子とメモリ上のアドレス(参照)とのバインディングの固定」を意味するに過ぎない。
JavaScriptの変数宣言は、V8のコンパイルフェーズ(Ignitionによるバイトコード生成とTurboFanによる最適化)において、スコープ内のレキシカル環境レコード(Lexical Environment Record)に登録される。
- `let`および`var`:環境レコード内のスロットの値を後から書き換えることが許可されている。
- `const`:初期化時に割り当てられたメモリポインタ(アドレス)の書き換えを、構文解析およびランタイムの参照解決フェーズで厳格に禁止する。
// シニアエンジニアなら知るべき、ポインタの固定と実体の分離
const systemConfig = {
timeout: 3000,
retries: 3
};
// 1. これはエラーになる(ポインタの再代入を試みているため)
// TypeError: Assignment to constant variable.
// systemConfig = { timeout: 5000, retries: 5 };
// 2. しかし、これは何のエラーも発生せず成功する
systemConfig.timeout = 5000;
systemConfig.debugMode = true;
console.log(systemConfig);
// 出力: { timeout: 5000, retries: 3, debugMode: true }
この挙動が起きる理由は単純だ。変数 `systemConfig` が保持しているのは、オブジェクトの「実体」そのものではなく、V8のヒープ(Heap)領域上に確保されたオブジェクトへのメモリ参照アドレス(Pointer)だからである。`const`がロックしているのはそのアドレス値であり、アドレスの先にあるヒープ上のプロパティの書き換えまでは関知しない。
—
2. V8エンジンの視点:オブジェクトの物理構造と「隠しクラス(Hidden Class / Map)」
オブジェクトのプロパティが動的に変更されるとき、V8エンジン内部では何が起きているのか。ここを知らずしてモダンJSのパフォーマンスを語ることはできない。
V8は動的言語であるJavaScriptのオブジェクトプロパティアクセスを高速化するため、「隠しクラス(Hidden Class)」(V8内部用語では `Map` と呼ばれる)という概念を導入している。オブジェクトに新しいプロパティが追加されるたび、V8は新しい隠しクラスを生成し、プロパティのオフセット(メモリ上の相対位置)を管理する。
const serverMetrics = {
cpuUsage: 12.5
};
// この時点で、V8は { cpuUsage } に対するMap Aを生成する
serverMetrics.memoryUsage = 512;
// プロパティ追加により、V8は Map A から Map B へと遷移させる
`const`で定義されたオブジェクトであっても、そのプロパティが変更・追加されるたびに、V8のヒープ内では構造の遷移(Mapの移行)が起きている。もし、この構造変化がコードのホットパス(Hot Path:頻繁に実行されるループや関数)内でランダムに行われると、TurboFanによるインラインキャッシュ(Inline Caching: IC)の最適化が破綻し、メガモルフィック(Megamorphic)状態に陥ってパフォーマンスが急降下する。
つまり、`const`は最適化コンパイラに対して「このオブジェクトの参照先は変わらない」というヒントを与えはするが、オブジェクトの形状(Shape)の安定性やデータの不変性を保証するものでは一切ないのだ。
—
3. `Object.freeze` との決定的な違い
「オブジェクトを本当にイミュータブルにしたい」という場合、`const`ではなく `Object.freeze()` を使う必要がある。しかし、両者のレイヤーの違いを混同してはならない。
| 特性 | `const` | `Object.freeze()` |
| :— | :— | :— |
| 適用対象 | 変数バインディング(スコープ) | オブジェクトのインスタンス(ヒープ) |
| 効果 | 再代入の禁止 | プロパティの追加・削除・値の変更の禁止 |
| レイヤー | 構文・レキシカル環境レベル | ランタイム・オブジェクトメタデータレベル |
| 深度 | 1階層目のみ(シャロー) | 1階層目のみ(シャロー。深層は別途再帰的凍結が必要) |
‘use strict’;
const immutablePayload = Object.freeze({
apiKey: ‘secret_val_999’,
permissions: [‘read’, ‘write’]
});
// strictモード下では、プロパティの書き換えを試みるとTypeErrorが発生する
try {
immutablePayload.apiKey = ‘hacked_key’;
} catch (e) {
console.error(e.message);
// 出力: Cannot assign to read only property ‘apiKey’ of object ‘#
罠:シャロー(浅い)フリーズの限界
`Object.freeze()` もまた、デフォルトではシャロー(浅い)である。ネストされたオブジェクトや配列の内部までは凍結されない。
const deepConfig = Object.freeze({
db: {
host: ‘localhost’,
port: 5432
}
});
// ネストされたオブジェクトのプロパティは変更できてしまう!
deepConfig.db.port = 3306;
console.log(deepConfig.db.port); // 3306 (書き換わってしまっている)
真の不変性を担保するには、再帰的にオブジェクトを走査して凍結するユーティリティ関数(Deep Freeze)を実装するか、Immutable.jsなどの専用ライブラリ、あるいはTC39で提案されているレコード・タプル(Record & Tuple)の仕様動向を注視する必要がある。
—
4. セキュリティの闇:プロトタイプ汚染(Prototype Pollution)とサプライチェーンの崩壊
なぜ、`const`による「不変性の誤解」がセキュリティ上の致命傷になり得るのか。ここで、実際の脆弱性ハックの深層に踏い込もう。
Node.jsのバックエンドやフロントエンドのビルドツールチェーンにおいて、外部から入力されたJSONやクエリパラメータを再帰的にマージ(Deep Merge)する処理は散見される。このマージ処理に脆弱性(プロトタイプ汚染)が存在する場合、アノニマスなオブジェクトのプロパティ操作を通じて、グローバルな `Object.prototype` が書き換えられる。
// 脆弱なマージ関数のシミュレーション
function unsafeDeepMerge(target, source) {
for (let key in source) {
if (typeof source[key] === ‘object’ && source[key] !== null) {
if (!target[key]) target[key] = {};
unsafeDeepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// 攻撃者が悪意あるペイロードを送り込む
const maliciousPayload = JSON.parse(‘{“__proto__”: {“isAdmin”: true}}’);
const userSettings = {
theme: ‘dark’
};
// 実行
unsafeDeepMerge(userSettings, maliciousPayload);
// ここで何が起きているか?
const ordinaryUser = {};
console.log(ordinaryUser.isAdmin); // true ?! 全てのオブジェクトが汚染されている
なぜ `const` はこの攻撃を防げないのか?
攻撃者は `userSettings` という変数そのものを再代入(`userSettings = …`)しようとしているわけではない。彼らが突いているのは、ヒープ上に存在するオブジェクトのプロトタイプチェーンの書き換えである。`const`は変数の再代入を防ぐ盾であって、オブジェクトの動的な拡張性やプロトタイプ汚染というランタイムレベルのメモリインジェクションを防ぐ防壁にはなり得ないのだ。
この汚染がサプライチェーン(依存パッケージ)の奥深くで発生した場合、認証バイパス、意図しない特権昇格、さらにはテンプレートエンジンやシリアライザの脆弱性と連鎖し、リモートコード実行(RCE)へと直結する。
—
5. 堅牢なアーキテクチャのための防衛策
ランタイムの特性を熟知したプロフェッショナルとして、我々はどのようにコードベースを防御すべきか。実践的なアプローチを提示する。
1. デフォルトの `const` + 意図的な `Object.freeze` の併用
状態の共有や設定データなど、変更されてはならないオブジェクトは、定義と同時に `Object.freeze()`(または必要に応じたDeep Freeze)を適用する。
2. 入力値のサニタイズとスキーマ検証
外部から受け取るデータは、ZodやJoiなどの厳格なスキーマバリデータを通し、`__proto__`, `constructor`, `prototype` といった危険なキーの侵入をランタイムの入口で確実に遮断する。
3. プロトタイプを持たないオブジェクトの活用
ディクショナリ(連想配列)としてオブジェクトを使用する場合は、プロトタイプチェーンによる汚染リスクを根絶するため、`Object.create(null)` を用いてプロトタイプを持たないオブジェクト(Null-prototype objects)を生成する。
// プロトタイプを持たない安全なディクショナリの生成
const safeDictionary = Object.create(null);
safeDictionary.theme = ‘dark’;
console.log(safeDictionary.__proto__); // undefined
// これにより、Object.prototype への汚染経路を物理的に遮断できる
—
結びにかえて
`const` は、コードの可読性を高め、不要な再代入バグを防ぐための素晴らしい構文である。しかし、それを「安全な不変データ構造の保証」と履き違えた瞬間、V8のメモリモデルの隙を突いたバグや、サプライチェーンの脆弱性が牙をむく。
JavaScriptという言語の深淵を捉え、ランタイムがメモリ上でどう振る舞っているのかを常に脳内でトレースすること。それこそが、真に堅牢でスケーラブルなシステムを構築する唯一の道なのである。