From daa9206daf86d5fe49ce480a21080f2b6f0adc60 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Thu, 30 Apr 2026 11:52:04 -0400 Subject: [PATCH] Reject invalid anchors in Draft 7 and older Signed-off-by: Juan Cruz Viotti --- src/core/jsonschema/frame.cc | 24 ++++- .../jsonschema_frame_draft3_test.cc | 41 +++++++ .../jsonschema_frame_draft4_test.cc | 50 +++++++++ .../jsonschema_frame_draft6_test.cc | 75 +++++++++++++ .../jsonschema_frame_draft7_test.cc | 100 ++++++++++++++++++ 5 files changed, 285 insertions(+), 5 deletions(-) diff --git a/src/core/jsonschema/frame.cc b/src/core/jsonschema/frame.cc index 33e1ecf02..f73d8ac44 100644 --- a/src/core/jsonschema/frame.cc +++ b/src/core/jsonschema/frame.cc @@ -47,7 +47,7 @@ auto is_valid_anchor_2020_12(const std::string_view name) -> bool { return true; } -auto is_valid_anchor_2019_09(const std::string_view name) -> bool { +auto is_valid_anchor(const std::string_view name) -> bool { if (name.empty()) { return false; } @@ -135,7 +135,7 @@ auto find_anchors(const sourcemeta::core::JSON &schema, const auto *anchor_2019{schema.try_at("$anchor")}; if (anchor_2019 && anchor_2019->is_string()) { const std::string_view anchor_view{anchor_2019->to_string()}; - if (!is_valid_anchor_2019_09(anchor_view)) { + if (!is_valid_anchor(anchor_view)) { throw sourcemeta::core::SchemaKeywordError("$anchor", anchor_view, "Invalid anchor value"); } @@ -168,8 +168,16 @@ auto find_anchors(const sourcemeta::core::JSON &schema, // A bare "#" carries no anchor name, so we treat it as no anchor at // all. if (id_view.starts_with('#') && id_view.size() > 1) { - // The original string is "#fragment", skip the '#' - result.emplace_back(id_view.substr(1), AnchorType::Static); + const std::string_view anchor_view{id_view.substr(1)}; + // Per Draft 7 / 6 spec, the plain-name fragment in `$id` must + // begin with a letter `[A-Za-z]` followed by any number of + // letters, digits, hyphens, underscores, colons, or periods. + if (!is_valid_anchor(anchor_view)) { + throw sourcemeta::core::SchemaKeywordError("$id", id_view, + "Invalid anchor value"); + } + + result.emplace_back(anchor_view, AnchorType::Static); } } } @@ -186,7 +194,13 @@ auto find_anchors(const sourcemeta::core::JSON &schema, // A bare "#" carries no anchor name, so we treat it as no anchor at // all. if (id_view.starts_with('#') && id_view.size() > 1) { - // The original string is "#fragment", skip the '#' + // Draft 4 imposes no plain-name pattern on the fragment, but the + // value must still be a valid URI reference per RFC 3986 + if (!sourcemeta::core::URI::is_uri_reference(id_view)) { + throw sourcemeta::core::SchemaKeywordError( + "id", id_view, "The identifier is not a valid URI"); + } + result.emplace_back(id_view.substr(1), AnchorType::Static); } } diff --git a/test/jsonschema/jsonschema_frame_draft3_test.cc b/test/jsonschema/jsonschema_frame_draft3_test.cc index e871997db..182048cb9 100644 --- a/test/jsonschema/jsonschema_frame_draft3_test.cc +++ b/test/jsonschema/jsonschema_frame_draft3_test.cc @@ -901,3 +901,44 @@ TEST(JSONSchema_frame_draft3, top_level_id_empty_string) { EXPECT_FRAME_LOCATION_REACHABLE(frame, Static, "", frame.root()); } + +TEST(JSONSchema_frame_draft3, id_fragment_rejected) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "id": "#foo", + "$schema": "http://json-schema.org/draft-03/schema#" + })JSON"); + + sourcemeta::core::SchemaFrame frame{ + sourcemeta::core::SchemaFrame::Mode::References}; + + try { + frame.analyse(document, sourcemeta::core::schema_walker, + sourcemeta::core::schema_resolver); + FAIL(); + } catch (const sourcemeta::core::SchemaFrameError &error) { + EXPECT_EQ(error.identifier(), "#foo"); + } catch (...) { + FAIL(); + } +} + +TEST(JSONSchema_frame_draft3, id_fragment_invalid_whitespace) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "id": "#foo bar", + "$schema": "http://json-schema.org/draft-03/schema#" + })JSON"); + + sourcemeta::core::SchemaFrame frame{ + sourcemeta::core::SchemaFrame::Mode::References}; + + try { + frame.analyse(document, sourcemeta::core::schema_walker, + sourcemeta::core::schema_resolver); + FAIL(); + } catch (const sourcemeta::core::SchemaKeywordError &error) { + EXPECT_EQ(error.keyword(), "id"); + EXPECT_EQ(error.value(), "#foo bar"); + } catch (...) { + FAIL(); + } +} diff --git a/test/jsonschema/jsonschema_frame_draft4_test.cc b/test/jsonschema/jsonschema_frame_draft4_test.cc index 473a85ff3..ea9091426 100644 --- a/test/jsonschema/jsonschema_frame_draft4_test.cc +++ b/test/jsonschema/jsonschema_frame_draft4_test.cc @@ -1378,3 +1378,53 @@ TEST(JSONSchema_frame_draft4, top_level_id_empty_string) { EXPECT_FRAME_LOCATION_REACHABLE(frame, Static, "", frame.root()); } + +TEST(JSONSchema_frame_draft4, id_fragment_invalid_whitespace) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": { + "foo": { + "id": "#foo bar" + } + } + })JSON"); + + sourcemeta::core::SchemaFrame frame{ + sourcemeta::core::SchemaFrame::Mode::References}; + + try { + frame.analyse(document, sourcemeta::core::schema_walker, + sourcemeta::core::schema_resolver); + FAIL(); + } catch (const sourcemeta::core::SchemaKeywordError &error) { + EXPECT_EQ(error.keyword(), "id"); + EXPECT_EQ(error.value(), "#foo bar"); + } catch (...) { + FAIL(); + } +} + +TEST(JSONSchema_frame_draft4, id_fragment_invalid_angle_bracket) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": { + "foo": { + "id": "#foo