`const` に騙されるな。V8メモリ空間から理解する `Object.freeze` とプロトタイプ汚染防御の極意
チームのコードレビューを行っていると、頻繁に目にする誤解があります。
// レビューでよく見かける「安全だと思われている」コード
const CONFIG = {
apiEndpoint: “https://api.internal/v1”,
options: {
timeout: 5000,
},
};
「`const` で宣言しているから、この設定値は絶対に変更されず安全です」——そう自信満々に答えるメンバーが絶えません。
明確に言いましょう。`const` はオブジェクトの破壊的変更(ミューテーション)を1ミリも防ぎません。
`const` が保証するのは「識別子とメモリアドレスのバインディング(再代入)の禁止」だけであり、ヒープメモリ上に存在するオブジェクト自体のプロパティ変更や、最悪の場合はプロトタイプチェーンを経由したセキュリティホール(プロトタイプ汚染:Prototype Pollution)の侵入を一切阻止できないのです。
本稿では、V8エンジンの内部挙動からプロトタイプ汚染の攻撃ベクトル、そして `const` と `Object.freeze`(さらにプロトタイプ非所持オブジェクト)を組み合わせた「プロダクションレベルの極限の防御的プログラミング」を解説します。
—
1. `const` と `Object.freeze` の決定的な違い:V8メモリ空間での視点
まず、JavaScriptランタイム(V8エンジン)のメモリ空間で何が起きているのかを正しくメンタルモデルとして構築してください。
`const` の実態:環境レコード(Environment Record)の固定
`const` 変数を宣言した際、V8は実行文脈の環境レコード(Lexical Environment)上に識別子をバインドします。`const` はこのバインディングを「書き換え不可(Immutable Binding)」にするだけです。
const config = { retries: 3 };
// ❌ TypeError: Assignment to constant variable.
// Lexical Environmentのバインディングを書き換えようとしたためV8が例外を投げる
config = { retries: 5 };
// ⭕️ 成功してしまう!
// ヒープ領域にあるオブジェクト本体の内部状態は変更可能(Mutable)なまま
config.retries = 5;
ヒープ領域に置かれた `{ retries: 3 }` というオブジェクト自体は、何ら制約を受けていません。
`Object.freeze` の実態:Property Descriptor の一括変更と Integrity Level
一方、`Object.freeze(obj)` を呼び出すと、V8はヒープ上のオブジェクトの完全性レベル(Integrity Level)を「Frozen」に変更します。
具体的には、対象オブジェクトのすべての所有プロパティに対して以下の内部処理が実行されます。
1. Property Descriptor の変更: すべてのデータプロパティの `configurable` フラグと `writable` フラグが `false` に設定される。
2. 拡張不可(Non-extensible)化: オブジェクト内部のスロット `[[Extensible]]` が `false` に設定され、新しいプロパティの追加が永久に禁止される。
const frozenConfig = Object.freeze({ retries: 3 });
// 各プロパティの Descriptor を確認してみる
console.log(Object.getOwnPropertyDescriptor(frozenConfig, ‘retries’));
/
{
value: 3,
writable: false, // ❌ 書き換え不可
enumerable: true,
configurable: false // ❌ 削除や属性変更不可
}
/
`const` は「変数の参照」を守り、`Object.freeze` は「ヒープ上の値そのもの」を守ります。堅牢なシステムを構築するには、この両者を組み合わせることが不可欠です。
—
2. なぜ「浅いフリーズ」ではプロトタイプ汚染を防げないのか
ここからが本題です。標準の `Object.freeze()` には重大な弱点があります。それは「浅い(Shallow)フリーズ」しか行わないという点です。
浅いフリーズの罠
次のコードを見てください。
const appConfig = Object.freeze({
env: “production”,
db: {
host: “localhost”, // ネストされたオブジェクト
port: 5432
}
});
// トップレベルは凍結されている
appConfig.env = “development”; // 無視される(Strict ModeではTypeError)
// ❌ ネストされたオブジェクトは凍結されていない!
appConfig.db.host = “malicious-db.com”; // 容易に書き換えられてしまう!
V8は `appConfig` の所有するプロパティ(`env` と `db`)の Descriptor のみを書き換えます。`db` プロパティの `value` である「子オブジェクトへの参照(メモリアドレス)」は変更不可になりますが、参照先の子オブジェクトそのものの Descriptor は変更されません。
プロトタイプ汚染(Prototype Pollution)の侵入経路
この「浅さ」と「JavaScriptのプロトタイプ継承」が組み合わさることで、壊滅的なセキュリティ脆弱性が生まれます。
サードパーティライブラリのパース処理や、不完全なオブジェクトのマージ処理(`deepMerge` 等)において、攻撃者が以下のような悪意あるJSONを注入したとします。
{
“__proto__”: {
“admin”: true
}
}
未対策のコードがこれを既存のオブジェクトにマージすると、JavaScriptのすべてのオブジェクトの頂点にある `Object.prototype` に `admin = true` が注入されます。
// プロトタイプ汚染が成立した場合の挙動
const user = {};
console.log(user.admin); // true !!! (本来存在しないはずの権限が参照できてしまう)
もし設定オブジェクトが完全に凍結されておらず、かつルートプロトタイプと繋がっていた場合、システム全体のアクセス制御やバリデーションロジックが突破されます。
—
3. 実務で勝つための設計パターン:プロトタイプ非所持 ✕ 完全深層フリーズ
プロトタイプ汚染を根本からシャットアウトし、本番環境で絶対にバグを起こさないために、私たちは2つの防壁を構築する必要があります。
1. プロトタイプ自体の除去 (`Object.create(null)` または `Object.setPrototypeOf`)
2. 再帰的な深層フリーズ (`deepFreeze`)
堅牢な `deepFreeze` と安全な設定オブジェクトの生成
以下は、循環参照や特殊オブジェクト(TypedArray / Map / Set 等)の考慮、さらにはプロトタイプの切り離しまで視野に入れた、プロダクションクオリティの完全防壁実装です。そのままプロジェクトユーティリティとしてコピー&ペーストして使用できます。
/
- オブジェクトとその配下のすべてのネストされたオブジェクトを再帰的に完全凍結する。
- 同時に、プロトタイプチェーンを遮断することでプロトタイプ汚染を完璧に防御する。
/
export function deepFreeze
// 循環参照の検出(無限ループによるコールスタックオーバーフローを防止)
if (visited.has(obj)) {
return obj;
}
visited.add(obj);
// オブジェクトの全所有キー(Symbolや非列挙プロパティ含む)を取得
const propNames = Reflect.ownKeys(obj);
for (const name of propNames) {
const value = Reflect.get(obj, name);
// 子要素がオブジェクトかつ未凍結であれば再帰的にフリーズ
if (value !== null && (typeof value === “object” || typeof value === “function”)) {
deepFreeze(value, visited);
}
}
// オブジェクト自体を凍結
return Object.freeze(obj);
}
/
- プロトタイプを持たない「純粋な辞書オブジェクト」を作成し、深層凍結する。
- プロトタイプ汚染攻撃ベクトルを根本から無力化する。
/
export function createSecureConfig
// 1. プロトタイプが null の新しい空オブジェクトを生成
const nullProtoObj = Object.create(null);
// 2. ソースオブジェクトのデータを安全にコピー
Object.assign(nullProtoObj, rawConfig);
// 3. 深層フリーズを適用
return deepFreeze(nullProtoObj);
}
実行例と検証
上記のユーティリティを実際に使用した場合の挙動を追ってみましょう。
// あえて悪意あるプロトタイプ操作を含んだ設定オブジェクトを模倣
const unsafeInput = {
api: {
timeout: 3000,
headers: {
“X-App-Version”: “1.0.0”
}
}
};
// 安全な凍結オブジェクトを生成
const SECURE_CONFIG = createSecureConfig(unsafeInput);
// — 検証1: トップレベルおよびネストプロパティの書き換え —
try {
// Strict Mode下では TypeError が発生する
SECURE_CONFIG.api.timeout = 9999;
} catch (e) {
console.error(“【書き換え防止】:”, e.message);
// 出力: Cannot assign to read only property ‘timeout’ of object ‘#
// — 検証2: プロトタイプ汚染の無効化 —
console.log(SECURE_CONFIG.__proto__);
// 出力: undefined (プロトタイプチェーンが存在しないため、__proto__ 経由の汚染が不可能)
// — 検証3: 不正なプロパティ追加の防止 —
try {
SECURE_CONFIG.api.headers[“X-Malicious-Header”] = “injected”;
} catch (e) {
console.error(“【プロパティ追加防止】:”, e.message);
// 出力: Cannot add property X-Malicious-Header, object is not extensible
}
—
4. V8のパフォーマンス面における注意点とトレードオフ
テクニカルリードとして見逃せないのがパフォーマンスへの影響です。`Object.freeze` を乱用すると、V8エンジンの最適化機構と反する挙動を引き起こす場合があります。
Hidden Class(Shape)と Fast Properties
V8は、JavaScriptオブジェクトのプロパティアクセスを高速化するために内部で Hidden Class(Shape) を動的に生成し、インラインキャッシュ(Inline Cache: IC) を効かせています。
オブジェクトに対して `Object.freeze` を呼び出すと、V8はそのオブジェクトの Shape を「Frozen 専用の Shape」へと強制移行させます。
1. メモリ使用量: 大量の動的な小オブジェクトに対して `Object.freeze` を都度適用すると、V8内部で異例な数の Shape が生成され、メモリのオーバーヘッドが増加する。
2. Dictionary Mode への転落: 複雑なオブジェクト構造を凍結すると、V8が Fast Properties(配列状の高速アクセス)を諦め、Dictionary Mode(ハッシュテーブル検索による遅いアクセス) にフォールバックすることがある。
現場での最適化指針
パフォーマンスと安全性を両立させるための鉄則は以下の通りです。
- アプリケーションの起動時(Bootstrapping)にのみ実行する:
定数定義、システム設定、機能フラグ(Feature Flags)など、アプリケーションライフサイクル中で数回しか生成されないグローバルな構造体に対してのみ `createSecureConfig` / `deepFreeze` を適用する。
- 高頻度で生成・破棄されるループ内のオブジェクトには適用しない:
描画フレームごと(`requestAnimationFrame` 内)や、何万回も回る `map` ループの中で生成されるテンポラリオブジェクトをフリーズさせるのはアンチパターン。そこは TypeScript の `readonly` 型によるコンパイルタイムの型チェックに任せるべきです。
—
5. 結論:型(TypeScript)とランタイム(JavaScript)の二重防御線を張れ
Webアプリケーション開発において、安全性を他者に依存することは最大のリスクです。
TypeScriptの `Readonly
1. 参照の固定: `const` を使用して識別子の再代入を防ぐ。
2. 値の凍結: `deepFreeze` でヒープ上のオブジェクト構造を固定する。
3. プロトタイプの破棄: `Object.create(null)` でプロトタイプ汚染の攻撃面(Attack Surface)をゼロにする。
この3層の防御アプローチをマスターし、単なる「動くコード」ではなく、V8エンジンレベルで堅牢かつ美しいプロダクションコードを設計・実装していきましょう。