From f48d6481f1690cf2f117212f3380d16d687c18b5 Mon Sep 17 00:00:00 2001 From: Otar Chekurishvili Date: Mon, 17 Aug 2026 19:12:38 +0400 Subject: [PATCH] Add opt-in JSON comments and trailing comma parsing --- ext/json/json.c | 7 +- ext/json/json.stub.php | 11 +++ ext/json/json_arginfo.h | 4 +- ext/json/json_parser.y | 22 +++++ ext/json/json_scanner.re | 84 ++++++++++++++++ ext/json/php_json.h | 4 + .../tests/json_decode_allow_comments.phpt | 70 ++++++++++++++ .../json_decode_allow_comments_errors.phpt | 67 +++++++++++++ .../json_decode_allow_comments_utf8.phpt | 57 +++++++++++ .../json_decode_allow_trailing_commas.phpt | 96 +++++++++++++++++++ ...on_decode_relaxed_syntax_interactions.phpt | 78 +++++++++++++++ ...ast_error_msg_error_location_comments.phpt | 40 ++++++++ ext/json/tests/json_validate_002.phpt | 4 +- sapi/fuzzer/fuzzer-json.c | 16 +++- 14 files changed, 553 insertions(+), 7 deletions(-) create mode 100644 ext/json/tests/json_decode_allow_comments.phpt create mode 100644 ext/json/tests/json_decode_allow_comments_errors.phpt create mode 100644 ext/json/tests/json_decode_allow_comments_utf8.phpt create mode 100644 ext/json/tests/json_decode_allow_trailing_commas.phpt create mode 100644 ext/json/tests/json_decode_relaxed_syntax_interactions.phpt create mode 100644 ext/json/tests/json_last_error_msg_error_location_comments.phpt diff --git a/ext/json/json.c b/ext/json/json.c index 04a62f52152f..0ebf5998a5a8 100644 --- a/ext/json/json.c +++ b/ext/json/json.c @@ -351,8 +351,11 @@ PHP_FUNCTION(json_validate) ZEND_PARSE_PARAMETERS_END(); - if ((options != 0) && (options != PHP_JSON_INVALID_UTF8_IGNORE)) { - zend_argument_value_error(3, "must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE)"); + if ((options & ~(PHP_JSON_INVALID_UTF8_IGNORE + | PHP_JSON_ALLOW_COMMENTS + | PHP_JSON_ALLOW_TRAILING_COMMAS)) != 0) { + zend_argument_value_error(3, "must be a valid flag (allowed flags: " + "JSON_INVALID_UTF8_IGNORE, JSON_ALLOW_COMMENTS, JSON_ALLOW_TRAILING_COMMAS)"); RETURN_THROWS(); } diff --git a/ext/json/json.stub.php b/ext/json/json.stub.php index a805c3893dd1..737d341e9ca2 100644 --- a/ext/json/json.stub.php +++ b/ext/json/json.stub.php @@ -90,6 +90,17 @@ */ const JSON_THROW_ON_ERROR = UNKNOWN; +/** + * @var int + * @cvalue PHP_JSON_ALLOW_COMMENTS + */ +const JSON_ALLOW_COMMENTS = UNKNOWN; +/** + * @var int + * @cvalue PHP_JSON_ALLOW_TRAILING_COMMAS + */ +const JSON_ALLOW_TRAILING_COMMAS = UNKNOWN; + /** * @var int * @cvalue PHP_JSON_ERROR_NONE diff --git a/ext/json/json_arginfo.h b/ext/json/json_arginfo.h index 87ba9cce3afd..906491686cbd 100644 --- a/ext/json/json_arginfo.h +++ b/ext/json/json_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit json.stub.php instead. - * Stub hash: 0ceb50047401c4b9e878c09cc518eacc274f7fff */ + * Stub hash: 405dc5ea03cb0ed560c3717cad38203b2f55d870 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_json_encode, 0, 1, MAY_BE_STRING|MAY_BE_FALSE) ZEND_ARG_TYPE_INFO(0, value, IS_MIXED, 0) @@ -68,6 +68,8 @@ static void register_json_symbols(int module_number) REGISTER_LONG_CONSTANT("JSON_INVALID_UTF8_IGNORE", PHP_JSON_INVALID_UTF8_IGNORE, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("JSON_INVALID_UTF8_SUBSTITUTE", PHP_JSON_INVALID_UTF8_SUBSTITUTE, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("JSON_THROW_ON_ERROR", PHP_JSON_THROW_ON_ERROR, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("JSON_ALLOW_COMMENTS", PHP_JSON_ALLOW_COMMENTS, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("JSON_ALLOW_TRAILING_COMMAS", PHP_JSON_ALLOW_TRAILING_COMMAS, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("JSON_ERROR_NONE", PHP_JSON_ERROR_NONE, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("JSON_ERROR_DEPTH", PHP_JSON_ERROR_DEPTH, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("JSON_ERROR_STATE_MISMATCH", PHP_JSON_ERROR_STATE_MISMATCH, CONST_PERSISTENT); diff --git a/ext/json/json_parser.y b/ext/json/json_parser.y index 0fd3e2c4e364..742f7141042f 100644 --- a/ext/json/json_parser.y +++ b/ext/json/json_parser.y @@ -119,6 +119,17 @@ members: } } | member + | member ',' + { + if (!(parser->scanner.options & PHP_JSON_ALLOW_TRAILING_COMMAS)) { + if (!parser->scanner.errcode) { + parser->scanner.errcode = PHP_JSON_ERROR_SYNTAX; + } + zval_ptr_dtor_nogc(&$1); + YYERROR; + } + ZVAL_COPY_VALUE(&$$, &$1); + } ; member: @@ -175,6 +186,17 @@ elements: } } | element + | element ',' + { + if (!(parser->scanner.options & PHP_JSON_ALLOW_TRAILING_COMMAS)) { + if (!parser->scanner.errcode) { + parser->scanner.errcode = PHP_JSON_ERROR_SYNTAX; + } + zval_ptr_dtor_nogc(&$1); + YYERROR; + } + ZVAL_COPY_VALUE(&$$, &$1); + } ; element: diff --git a/ext/json/json_scanner.re b/ext/json/json_scanner.re index be62875a00e0..6d91975fd5b7 100644 --- a/ext/json/json_scanner.re +++ b/ext/json/json_scanner.re @@ -102,6 +102,11 @@ void php_json_scanner_init(php_json_scanner *s, const char *str, size_t str_len, int php_json_scan(php_json_scanner *s) { + /* Comments are fully consumed before returning, so their opener location can remain local. */ + php_json_ctype *comment_token = s->cursor; + php_json_ctype *comment_line_start = s->line_start; + uint64_t comment_line = s->line; + ZVAL_NULL(&s->value); std: @@ -203,6 +208,23 @@ std: goto std; } WS { goto std; } + "//" { + if (!(s->options & PHP_JSON_ALLOW_COMMENTS)) { + s->errcode = PHP_JSON_ERROR_SYNTAX; + return PHP_JSON_T_ERROR; + } + PHP_JSON_CONDITION_SET_AND_GOTO(COMMENT_LINE); + } + "/*" { + if (!(s->options & PHP_JSON_ALLOW_COMMENTS)) { + s->errcode = PHP_JSON_ERROR_SYNTAX; + return PHP_JSON_T_ERROR; + } + comment_token = s->token; + comment_line_start = s->line_start; + comment_line = s->line; + PHP_JSON_CONDITION_SET_AND_GOTO(COMMENT_BLOCK); + } EOI { if (s->limit < s->cursor) { return PHP_JSON_T_EOI; @@ -230,6 +252,68 @@ std: s->errcode = PHP_JSON_ERROR_UTF8; return PHP_JSON_T_ERROR; } + "\r\n" { + s->line++; + s->line_start = s->cursor; + PHP_JSON_CONDITION_SET(JS); + goto std; + } + [\r\n] { + s->line++; + s->line_start = s->cursor; + PHP_JSON_CONDITION_SET(JS); + goto std; + } + EOI { + if (s->limit < s->cursor) { + s->token = s->limit; + PHP_JSON_CONDITION_SET(JS); + return PHP_JSON_T_EOI; + } + PHP_JSON_CONDITION_GOTO(COMMENT_LINE); + } + UTF8 { PHP_JSON_CONDITION_GOTO(COMMENT_LINE); } + ANY { + if (s->options & (PHP_JSON_INVALID_UTF8_IGNORE | PHP_JSON_INVALID_UTF8_SUBSTITUTE)) { + PHP_JSON_CONDITION_GOTO(COMMENT_LINE); + } + s->token = s->cursor - 1; + s->errcode = PHP_JSON_ERROR_UTF8; + return PHP_JSON_T_ERROR; + } + "*/" { + PHP_JSON_CONDITION_SET(JS); + goto std; + } + "\r\n" { + s->line++; + s->line_start = s->cursor; + PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); + } + [\r\n] { + s->line++; + s->line_start = s->cursor; + PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); + } + EOI { + if (s->limit < s->cursor) { + s->token = comment_token; + s->line_start = comment_line_start; + s->line = comment_line; + s->errcode = PHP_JSON_ERROR_SYNTAX; + return PHP_JSON_T_ERROR; + } + PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); + } + UTF8 { PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); } + ANY { + if (s->options & (PHP_JSON_INVALID_UTF8_IGNORE | PHP_JSON_INVALID_UTF8_SUBSTITUTE)) { + PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); + } + s->token = s->cursor - 1; + s->errcode = PHP_JSON_ERROR_UTF8; + return PHP_JSON_T_ERROR; + } EOI { if (s->limit < s->cursor) { s->errcode = PHP_JSON_ERROR_SYNTAX; diff --git a/ext/json/php_json.h b/ext/json/php_json.h index f20b20964a71..0145a0dfaa6c 100644 --- a/ext/json/php_json.h +++ b/ext/json/php_json.h @@ -89,6 +89,10 @@ static inline void php_json_error_details_clear(php_json_error_details *out) { #define PHP_JSON_INVALID_UTF8_SUBSTITUTE (1<<21) #define PHP_JSON_THROW_ON_ERROR (1<<22) +/* json_validate() and json_decode() common options */ +#define PHP_JSON_ALLOW_COMMENTS (1<<23) +#define PHP_JSON_ALLOW_TRAILING_COMMAS (1<<24) + /* default depth */ #define PHP_JSON_PARSER_DEFAULT_DEPTH 512 diff --git a/ext/json/tests/json_decode_allow_comments.phpt b/ext/json/tests/json_decode_allow_comments.phpt new file mode 100644 index 000000000000..a613ac2888cc --- /dev/null +++ b/ext/json/tests/json_decode_allow_comments.phpt @@ -0,0 +1,70 @@ +--TEST-- +json_decode() and json_validate() with JSON_ALLOW_COMMENTS +--FILE-- + +--EXPECT-- +{"a":[1,2],"b":3} +bool(true) +[1,2] +bool(true) +[] +bool(true) +[] +bool(true) +1 +bool(true) +2 +bool(true) +3 +bool(true) +4 +bool(true) +6 +bool(true) +7 +bool(true) +["//","/* not a comment */","https://php.net/a/*b*/"] +bool(true) +5 +bool(true) +8 +bool(true) diff --git a/ext/json/tests/json_decode_allow_comments_errors.phpt b/ext/json/tests/json_decode_allow_comments_errors.phpt new file mode 100644 index 000000000000..c5f50ec6c815 --- /dev/null +++ b/ext/json/tests/json_decode_allow_comments_errors.phpt @@ -0,0 +1,67 @@ +--TEST-- +JSON_ALLOW_COMMENTS rejects malformed or misplaced comments +--FILE-- +getCode(), ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 1:16 +NULL +4: Syntax error near location 1:19 +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 1:2 +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 1:26 +NULL +4: Syntax error near location 1:22 +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 3:3 +JsonException: 4: Syntax error near location 2:2 diff --git a/ext/json/tests/json_decode_allow_comments_utf8.phpt b/ext/json/tests/json_decode_allow_comments_utf8.phpt new file mode 100644 index 000000000000..c7d78ff56196 --- /dev/null +++ b/ext/json/tests/json_decode_allow_comments_utf8.phpt @@ -0,0 +1,57 @@ +--TEST-- +JSON_ALLOW_COMMENTS interactions with malformed UTF-8 flags +--FILE-- +getCode(), ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +NULL +5 +array(1) { + [0]=> + string(2) "ab" +} +0 +array(1) { + [0]=> + string(5) "a�b" +} +0 +NULL +5 +int(1) +0 +bool(false) +5 +bool(true) +0 +5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:9 diff --git a/ext/json/tests/json_decode_allow_trailing_commas.phpt b/ext/json/tests/json_decode_allow_trailing_commas.phpt new file mode 100644 index 000000000000..ce111945791d --- /dev/null +++ b/ext/json/tests/json_decode_allow_trailing_commas.phpt @@ -0,0 +1,96 @@ +--TEST-- +json_decode() and json_validate() with JSON_ALLOW_TRAILING_COMMAS +--FILE-- +getCode(), ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +[1] +[1] +[1] +{"a":1} +[[1],{"a":2}] +error 4: Syntax error near location 1:2 +error 4: Syntax error near location 1:2 +error 4: Syntax error near location 1:4 +error 4: Syntax error near location 1:4 +error 4: Syntax error near location 1:8 +error 4: Syntax error near location 1:4 +error 4: Syntax error near location 1:4 +error 4: Syntax error near location 1:4 +[1] +{"a":1} +error 2: State mismatch (invalid or malformed JSON) near location 1:4 +error 2: State mismatch (invalid or malformed JSON) near location 1:8 +bool(true) +bool(true) +bool(false) +bool(true) +bool(false) +bool(false) +4: Syntax error near location 1:4 diff --git a/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt b/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt new file mode 100644 index 000000000000..068cffd20b34 --- /dev/null +++ b/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt @@ -0,0 +1,78 @@ +--TEST-- +JSON comment and trailing-comma flags interact correctly with existing behavior +--FILE-- + 1], RELAXED)); + +/* Association, bigint conversion, and duplicate-key behavior are unchanged. */ +var_dump(json_decode( + '/* before */{"n":9223372036854775808,"same":1,"same":2,}', + null, + 512, + RELAXED | JSON_OBJECT_AS_ARRAY | JSON_BIGINT_AS_STRING +)); +var_dump(json_decode('{"a":1,}', false, 512, JSON_ALLOW_TRAILING_COMMAS)); + +/* Comments and trailing commas do not consume nesting depth. */ +$nested = '/* before */[[0,],]'; +var_dump(json_decode($nested, true, 2, RELAXED)); +echo json_last_error(), ': ', json_last_error_msg(), "\n"; +var_dump(json_validate($nested, 3, RELAXED)); + +/* A trailing-comma reduction must not replace a more specific scanner error. */ +foreach ([ + "[1,\x01]", + "[1,\x80]", + '[1,"\\ud834"]', +] as $json) { + var_dump(json_decode($json, true, 512, RELAXED)); + echo json_last_error(), ': ', json_last_error_msg(), "\n"; +} + +/* json_validate() accepts only its documented combinations. */ +var_dump(json_validate('/* comment */[1,]', 512, RELAXED)); +var_dump(json_validate( + "/* \x80 */[1,]", + 512, + RELAXED | JSON_INVALID_UTF8_IGNORE +)); +foreach ([JSON_BIGINT_AS_STRING, JSON_INVALID_UTF8_SUBSTITUTE] as $invalidFlag) { + try { + json_validate('{}', flags: RELAXED | $invalidFlag); + } catch (ValueError $e) { + echo get_class($e), "\n"; + } +} + +?> +--EXPECTF-- +int(8388608) +int(16777216) +string(7) "{"x":1}" +array(2) { + ["n"]=> + string(19) "9223372036854775808" + ["same"]=> + int(2) +} +object(stdClass)#%d (1) { + ["a"]=> + int(1) +} +NULL +1: Maximum stack depth exceeded near location 1:14 +bool(true) +NULL +3: Control character error, possibly incorrectly encoded near location 1:4 +NULL +5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:4 +NULL +10: Single unpaired UTF-16 surrogate in unicode escape near location 1:4 +bool(true) +bool(true) +ValueError +ValueError diff --git a/ext/json/tests/json_last_error_msg_error_location_comments.phpt b/ext/json/tests/json_last_error_msg_error_location_comments.phpt new file mode 100644 index 000000000000..9d451d0be896 --- /dev/null +++ b/ext/json/tests/json_last_error_msg_error_location_comments.phpt @@ -0,0 +1,40 @@ +--TEST-- +JSON comment and trailing-comma errors preserve original source locations +--FILE-- + +--EXPECT-- +bool(false) +4: Syntax error near location 3:17 +bool(false) +4: Syntax error near location 4:4 +bool(false) +4: Syntax error near location 1:22 +bool(false) +4: Syntax error near location 3:3 +bool(false) +4: Syntax error near location 1:19 diff --git a/ext/json/tests/json_validate_002.phpt b/ext/json/tests/json_validate_002.phpt index 423564c4ad7c..c3b003ea57b3 100644 --- a/ext/json/tests/json_validate_002.phpt +++ b/ext/json/tests/json_validate_002.phpt @@ -36,10 +36,10 @@ string(8) "No error" Error: 0 json_validate(): Argument #2 ($depth) must be greater than 0 int(0) string(8) "No error" -Error: 0 json_validate(): Argument #3 ($flags) must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE) +Error: 0 json_validate(): Argument #3 ($flags) must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE, JSON_ALLOW_COMMENTS, JSON_ALLOW_TRAILING_COMMAS) int(0) string(8) "No error" -Error: 0 json_validate(): Argument #3 ($flags) must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE) +Error: 0 json_validate(): Argument #3 ($flags) must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE, JSON_ALLOW_COMMENTS, JSON_ALLOW_TRAILING_COMMAS) int(0) string(8) "No error" bool(false) diff --git a/sapi/fuzzer/fuzzer-json.c b/sapi/fuzzer/fuzzer-json.c index 5029cb9a585d..68f08f0b26aa 100644 --- a/sapi/fuzzer/fuzzer-json.c +++ b/sapi/fuzzer/fuzzer-json.c @@ -36,10 +36,22 @@ int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) { memcpy(data, Data, Size); data[Size] = '\0'; - for (int option = 0; option <=1; ++option) { + static const int options[] = { + 0, + PHP_JSON_OBJECT_AS_ARRAY, + PHP_JSON_ALLOW_COMMENTS, + PHP_JSON_ALLOW_TRAILING_COMMAS, + PHP_JSON_ALLOW_COMMENTS | PHP_JSON_ALLOW_TRAILING_COMMAS, + PHP_JSON_OBJECT_AS_ARRAY | PHP_JSON_BIGINT_AS_STRING + | PHP_JSON_ALLOW_COMMENTS | PHP_JSON_ALLOW_TRAILING_COMMAS, + PHP_JSON_ALLOW_COMMENTS | PHP_JSON_ALLOW_TRAILING_COMMAS | PHP_JSON_INVALID_UTF8_IGNORE, + PHP_JSON_ALLOW_COMMENTS | PHP_JSON_ALLOW_TRAILING_COMMAS | PHP_JSON_INVALID_UTF8_SUBSTITUTE, + }; + + for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); ++i) { zval result; php_json_parser parser; - php_json_parser_init(&parser, &result, data, Size, option, 10); + php_json_parser_init(&parser, &result, data, Size, options[i], 10); if (php_json_yyparse(&parser) == SUCCESS) { zval_ptr_dtor(&result); }