2025-08-30 #tips

JSON の未定義動作と JSON パーサごとの挙動の違い


RFC 8259 では,キーの重複については禁止されていません.
元は ECMAScript で扱うオブジェクトの形式でしたが,そこから派生し,標準化されたデータ構造となっています.
Web API や設定ファイルなど幅広い場所で使用されており,ECMAScript に限らず,多くの言語で扱うことができます.

The names within an object SHOULD be unique.

キーがユニークであるべきとは書かれていますが,複数あった場合の挙動については未定義動作となります.
エラーとするのか,先にあったほうを優先するのか,後ろなのか,そこは実装依存となっています.
そのため,パーサ間で挙動が異なる場合,思わぬ挙動になりえます.

例えば,以下のような JSON も有効です.ただ,重複時の挙動については定義されておらず,実装依存となります.

{ "key": "value1", "key": "value2" }

また,最近 RFC9839 が提案されました.
ここでは,問題のある「問題のある Unicode 文字」について書かれており,JSON などのデータ形式でテキストフィールドを扱う際の注意点について述べられています.
たとえば,以下の文字が特殊なものとして挙げられています.

  • \u0000 (NULL 文字)
  • \u0089 (C1 制御文字)
  • \uDEAD (不正なサロゲートペア)
  • \uD9BF\uDFFF (noncharacter)

実験

実際にどのような挙動になるのかいくつかのライブラリで試してみました.
実験のために実装したコードはこちら

  • C 言語 (cjson, jansson, json-c, parson)
  • C# (System.Text.Json, Newtonsoft.Json)
  • Go (encoding/json, jsonparser, jsoniter)
  • Java (Jackson, Gson, JSON-P)
  • Node.js (JSON.parse, fast-json-parse, JSON5)
  • PHP (json_decode, simdjson)
  • Python (json, orjson, simplejson, ujson)
  • Ruby (JSON, Oj, MultiJSON)
  • Rust (serde_json)

結果

キーの重複

ほとんどのパーサで,後ろの値を優先して扱われました.
唯一 Go の jsonparser のみ,先を優先する挙動で,もし扱う際には注意が必要です.
また,重複時にはエラーとするパーサもありました.

大きな数値

JavaScript の Number 型の制約により,Node.js では精度が失われる場合がありました (92233720368547758079223372036854776000).
静的型付けの言語では適切に型を与えておけば処理されていました.

Null 文字

C 言語系のパーサでは,Null 文字を文字列の終端として認識され,Null 文字以降を切り捨てる挙動のものがありました (e.g. admin\0evil -> admin).
一方で,多くの高級言語では Null 文字も含めて正しく処理されていました.

不正なサロゲートペア

最も挙動が分かれたテストケースでした.
エラーとするもの,置換文字に変換するもの,そのまま処理するものと,大きく分かれました.
セキュリティ要件が高いワークロードでは,エラーとするパーサを選ぶのが望ましいでしょう.

C1 制御文字・Noncharacter

ほとんどのパーサで正常に処理されました.

出力内容
=== JSON Parser Behavior Comparison ===
Running tests for all languages using containers...

Testing C...
=== JSON Parser Behavior Comparison (C) ===

1. Duplicate Keys Test
cJSON: first (success)
jansson: second (success)
json-c: second (success)
parson: error - parse error

1. Large Numbers Test
cJSON: 9223372036854775808 (success)
jansson: 9223372036854775807 (success)
json-c: 9223372036854775807 (success)
parson: 9223372036854775808 (success)

1. Null Character Test
cJSON: username="\x61\x64\x6d\x69\x6e" (success)
jansson: username="\x61\x64\x6d\x69\x6e" (success)
json-c: username="\x61\x64\x6d\x69\x6e" (success)
parson: skipped (known to crash on certain unicode sequences)

1. C1 Control Code Test
cJSON: username="\xc2\x89" (success)
jansson: username="\xc2\x89" (success)
json-c: username="\xc2\x89" (success)
parson: skipped (known to crash on certain unicode sequences)

1. Unpaired Surrogate Test
cJSON: error - \uDEAD"
}

jansson: error - invalid Unicode '\uDEAD' near '"\uDEAD"' at line 2 column 24
json-c: username="\xef\xbf\xbd" (success)
parson: skipped (known to crash on certain unicode sequences)

6. Noncharacter Test
cJSON: username="\xf1\xbf\xbf\xbf" (success)
jansson: username="\xf1\xbf\xbf\xbf" (success)
json-c: username="\xf1\xbf\xbf\xbf" (success)
parson: skipped (known to crash on certain unicode sequences)


Testing C#...
=== JSON Parser Behavior Comparison (C#) ===

1. Duplicate Keys Test
System.Text.Json: null (success)
Newtonsoft.Json: second (success)
Newtonsoft.Json (JObject): second (success)

2. Large Numbers Test
System.Text.Json: null (success)
Newtonsoft.Json: 9223372036854775807 (success)
Newtonsoft.Json (JObject): 9223372036854775807 (success)

3. Null Character Test
System.Text.Json: null (success)
Newtonsoft.Json: admin�evil (success)
Newtonsoft.Json (JObject): admin�evil (success)

4. C1 Control Code Test
System.Text.Json: null (success)
Newtonsoft.Json: ‰ (success)
Newtonsoft.Json (JObject): ‰ (success)

5. Unpaired Surrogate Test
System.Text.Json: null (success)
Newtonsoft.Json: � (success)
Newtonsoft.Json (JObject): � (success)

6. Noncharacter Test
System.Text.Json: null (success)
Newtonsoft.Json: 񿿿 (success)
Newtonsoft.Json (JObject): 񿿿 (success)


Testing Go...
=== JSON Parser Behavior Comparison (Go) ===
1. Duplicate Keys Test
  Standard json.Unmarshal: "second" (error: <nil>)
  jsonparser.GetString: "first" (error: <nil>)
  jsoniter.Unmarshal: "second" (error: <nil>)

2. Large Numbers Test
  Standard json.Unmarshal: 9223372036854775807 (error: <nil>)
  jsonparser.GetInt: 9223372036854775807 (error: <nil>)
  jsoniter.Unmarshal: 9223372036854775807 (error: <nil>)

3. Null Character Test
  Standard json.Unmarshal: "admin\x00evil" (error: <nil>)
  jsonparser.GetString: "admin\x00evil" (error: <nil>)
  jsoniter.Unmarshal: "admin\x00evil" (error: <nil>)

4. C1 Control Code Test
  Standard json.Unmarshal: "\u0089" (error: <nil>)
  jsonparser.GetString: "\u0089" (error: <nil>)
  jsoniter.Unmarshal: "\u0089" (error: <nil>)

5. Unpaired Surrogate Test
  Standard json.Unmarshal: "�" (error: <nil>)
  jsonparser.GetString: "" (error: Value looks like Number/Boolean/None, but can't find its end: ',' or '}' symbol)
  jsoniter.Unmarshal: "�" (error: <nil>)

6. Noncharacter Test
  Standard json.Unmarshal: "\U0007ffff" (error: <nil>)
  jsonparser.GetString: "\U0007ffff" (error: <nil>)
  jsoniter.Unmarshal: "\U0007ffff" (error: <nil>)


Testing Java...
=== JSON Parser Behavior Comparison (Java) ===

1. Duplicate Keys Test
Jackson: second (success)
Gson: second (success)
JSON-P: second (success)

2. Large Numbers Test
Jackson: no username field found (success)
Gson: no username field found (success)
JSON-P: no username field found (success)

3. Null Character Test
Jackson: admin�evil (success)
Gson: admin�evil (success)
JSON-P: admin�evil (success)

4. C1 Control Code Test
Jackson: ‰ (success)
Gson: ‰ (success)
JSON-P: ‰ (success)

5. Unpaired Surrogate Test
Jackson: ? (success)
Gson: ? (success)
JSON-P: ? (success)

6. Noncharacter Test
Jackson: 񿿿 (success)
Gson: 񿿿 (success)
JSON-P: 񿿿 (success)


Testing Node.js...
=== JSON Parser Behavior Comparison (Node.js) ===

1. Duplicate Keys Test
Standard JSON.parse: second (success)
fast-json-parse: second (success)
JSON5.parse: second (success)

2. Large Numbers Test
Standard JSON.parse: 9223372036854776000 (type: number) (success)
fast-json-parse: 9223372036854776000 (type: number) (success)
JSON5.parse: 9223372036854776000 (type: number) (success)

3. Null Character Test
Standard JSON.parse: admin�evil (success)
fast-json-parse: admin�evil (success)
JSON5.parse: admin�evil (success)

4. C1 Control Code Test
Standard JSON.parse: ‰ (success)
fast-json-parse: ‰ (success)
JSON5.parse: ‰ (success)

5. Unpaired Surrogate Test
Standard JSON.parse: � (success)
fast-json-parse: � (success)
JSON5.parse: � (success)

6. Noncharacter Test
Standard JSON.parse: 񿿿 (success)
fast-json-parse: 񿿿 (success)
JSON5.parse: 񿿿 (success)


Testing PHP...
=== JSON Parser Behavior Comparison (PHP) ===

1. Duplicate Keys Test
json_decode: second (success)
simdjson_decode: not available

2. Large Numbers Test
json_decode: 9223372036854775807 (type: integer) (success)
json_decode (BIGINT_AS_STRING): 9223372036854775807 (type: integer) (type: string)
simdjson_decode: not available

3. Null Character Test
json_decode: admin�evil (success)
simdjson_decode: not available

4. C1 Control Code Test
json_decode: ‰ (success)
simdjson_decode: not available

5. Unpaired Surrogate Test
json_decode: error - Single unpaired UTF-16 surrogate in unicode escape
simdjson_decode: not available

6. Noncharacter Test
json_decode: 񿿿 (success)
simdjson_decode: not available


Testing Python...
=== JSON Parser Behavior Comparison (Python) ===

1. Duplicate Keys Test
Standard json.loads: second (success)
ujson.loads: second (success)
orjson.loads: second (success)
simplejson.loads: second (success)

2. Large Numbers Test
Standard json.loads: 9223372036854775807 (success)
ujson.loads: 9223372036854775807 (success)
orjson.loads: 9223372036854775807 (success)
simplejson.loads: 9223372036854775807 (success)

3. Null Character Test
Standard json.loads: admin�evil (success)
ujson.loads: admin�evil (success)
orjson.loads: admin�evil (success)
simplejson.loads: admin�evil (success)

4. C1 Control Code Test
Standard json.loads: ‰ (success)
ujson.loads: ‰ (success)
orjson.loads: ‰ (success)
simplejson.loads: ‰ (success)

5. Unpaired Surrogate Test
Standard json.loads: error - 'utf-8' codec can't encode character '\udead' in position 21: surrogates not allowed
ujson.loads: error - 'utf-8' codec can't encode character '\udead' in position 13: surrogates not allowed
orjson.loads: error - invalid high surrogate in string: line 2 column 18 (char 19)
simplejson.loads: error - 'utf-8' codec can't encode character '\udead' in position 18: surrogates not allowed

6. Noncharacter Test
Standard json.loads: 񿿿 (success)
ujson.loads: 񿿿 (success)
orjson.loads: 񿿿 (success)
simplejson.loads: 񿿿 (success)


Testing Ruby...
=== JSON Parser Behavior Comparison (Ruby) ===
1. Duplicate Keys Test
Standard JSON: second (class: String) (success)
Oj: second (class: String) (success)
MultiJSON: second (class: String) (success)

2. Large Numbers Test
Standard JSON: 9223372036854775807 (class: Integer) (success)
Oj: 9223372036854775807 (class: Integer) (success)
MultiJSON: 9223372036854775807 (class: Integer) (success)

3. Null Character Test
Standard JSON: admin�evil (class: String) (success)
Oj: admin�evil (class: String) (success)
MultiJSON: admin�evil (class: String) (success)

4. C1 Control Code Test
Standard JSON: ‰ (class: String) (success)
Oj: ‰ (class: String) (success)
MultiJSON: ‰ (class: String) (success)

5. Unpaired Surrogate Test
Standard JSON: ��� (class: String) (success)
Oj: error - invalid escaped character (after username) at line 2, column 24 [parse.c:288]
MultiJSON: error - invalid escaped character (after username) at line 2, column 24 [parse.c:288]

6. Noncharacter Test
Standard JSON: 񿿿 (class: String) (success)
Oj: 񿿿 (class: String) (success)
MultiJSON: 񿿿 (class: String) (success)


Testing Rust...
=== JSON Parser Behavior Comparison (Rust) ===

1. Duplicate Keys Test
serde_json (struct): error - duplicate field `username` at line 3 column 14
serde_json (HashMap): "second" (success)
serde_json (Value): second (success)

2. Large Numbers Test
serde_json: Some(9223372036854775807) (success)
serde_json (u64): 9223372036854775807 (success)

3. Null Character Test
serde_json: Some("admin\0evil") (success)

4. C1 Control Code Test
serde_json: Some("\u{89}") (success)

5. Unpaired Surrogate Test
serde_json: error - lone leading surrogate in hex escape at line 2 column 23

6. Noncharacter Test
serde_json: Some("\u{7ffff}") (success)


All tests completed!

まとめ

今回の実験により,JSON パーサの挙動が言語やライブラリによって大きく異なることが明らかになりました.

キーの重複については,ほとんどのパーサが後の値を採用しますが,Go の jsonparser のように先の値を優先するものもあります.今回の実験では,jsonparser のみでしたが,世の中にはほかに先の値を優先する実装もあるでしょう.
大きな数値は JavaScript の Number 型の制約により精度が失われる場合があります.静的型付け言語でも,適切な型を与えておかないと,精度が失われる場合があります.
Null 文字は C 言語系のパーサで文字列の切り捨てが発生する可能性があり,セキュリティ上の懸念となり得ます.
最も注意が必要なのは不正なサロゲートペアで,エラーとするもの,置換文字に変換するもの,そのまま処理するものと大きく分かれました.

これらの違いを理解せずに開発を進めると,環境によって異なる挙動を示すアプリケーションになってしまう可能性があります.特に Web API やマイクロサービス間通信など,複数の環境でデータをやり取りする場合は,使用するパーサの特性を把握し,バリデーションの目的を意識して実装することが重要です.