Skip to content

Escape all CSDL attribute values on emit; decode entity references on parse (ARN-237) - #409

Open
NALLSUR wants to merge 2 commits into
mainfrom
surendranalla/arn-237-bug-csdl-parser-accepts-truncated-or-defaulted-schemas-and
Open

Escape all CSDL attribute values on emit; decode entity references on parse (ARN-237)#409
NALLSUR wants to merge 2 commits into
mainfrom
surendranalla/arn-237-bug-csdl-parser-accepts-truncated-or-defaulted-schemas-and

Conversation

@NALLSUR

@NALLSUR NALLSUR commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Emitter half of ARN-237: CSDL metadata emitted by emit_csdl_xml applied XML escaping to only 4 of ~30 attribute interpolation sites, so agent- or user-influenced identifiers (entity/property names, namespaces, DefaultValue, navigation-binding Path/Target, annotation terms, action/function references) could close their attribute and inject arbitrary markup — the same defect class as ARN-172. A property named Name"/><Property Name="Smuggled produced a second property element; a smuggled HasStream="true" altered the typed model on re-parse.

Two commits, each independently green (bisect-safe):

  1. Parser: decode XML entity references in attr_str (crates/temper-spec/src/csdl/parser/xml.rs) — attr_str returned the raw, still-escaped attribute bytes, so &amp;/&quot;/&#xA; surfaced as literal escape text in the typed model. This is load-bearing for commit 2, not incidental: the old emitter and parser bugs were compensating, and fixing emitter escaping alone would double-escape on every parse/emit/parse cycle (&&amp;&amp;amp;). No fixture in the repo contains an entity reference, so this commit is a behavioral no-op for the existing corpus; it is separated purely to keep the shared read-path change reviewable on its own.
  2. Emitter: escape every attribute value (crates/temper-spec/src/csdl/emit.rs) — every interpolated string now routes through xml_escape; the only remaining raw interpolations are numeric types (i64, u32, f64, bool), which cannot produce a metacharacter. xml_escape additionally escapes tab/LF/CR as character references (XML attribute-value normalization otherwise silently rewrites them to spaces on re-parse) and is now a single pass instead of five chained replace calls.

Tests

  • adversarial_identifiers_do_not_inject_markup — builds a document whose namespace, entity-type name, key, property name, DefaultValue, annotation term, container name, entity-set name/type, navigation-binding path/target, and action/function imports all carry "><Injected/>-style payloads; asserts no live markup is emitted, the output re-parses, and the typed model survives the round trip byte-identical (including that the smuggled HasStream="true" does not take effect). Satisfies the ARN-237 acceptance line "parse/emit/parse round trips preserve the typed model for adversarial XML characters".
  • whitespace_in_attribute_values_round_trips — pins the tab/LF/CR character-reference behavior.

Verification

  • cargo test -p temper-spec: 269 + 3 integration, green; verified each commit passes independently
  • cargo test -p temper-server (primary CSDL consumer, ~20 test targets incl. DST suites): ~750 passed, 0 new failures. The 2 failures in spec_validate_endpoint reproduce on unmodified main — root-caused to observe feature gating and filed as ARN-281
  • cargo fmt --check and cargo clippy --all-targets clean
  • Mandatory code review: PASS WITH FINDINGS; the test-coverage finding was addressed by extending the adversarial fixture, the other rides with the deferred work below

Deliberately deferred — strict-parser half of ARN-237

Rejecting truncated schemas (EOF-as-success), mandatory attributes, exact numeric parsing, unknown-structure rejection, and reference validation are not in this PR. Strictness strictly increases the set of persisted schemas that fail to load, which interacts directly with ARN-190 (one corrupt tenant CSDL aborts the entire registry restore). Sequencing it after ARN-190 avoids turning currently-booting servers into non-booting ones. One reviewer finding rides with it: unescape_value().ok() silently drops an optional attribute whose value contains a malformed entity reference; mandatory attributes still fail loudly via required_attr. The proper fix (attr_str → Result) is the strict-parser work.

Tradeoff, plainly: until the strict half lands, malformed CSDL still parses permissively — this PR only guarantees that what we emit is well-formed and injection-free, and that legal escaped content round-trips faithfully.

🤖 Generated with Claude Code

Greptile Summary

The PR hardens CSDL XML round trips by:

  • decoding XML entity and character references when parsing attribute values
  • escaping every string interpolated into emitted XML attributes
  • preserving tab, newline, and carriage-return characters through attribute normalization
  • adding adversarial injection and whitespace round-trip coverage

Confidence Score: 3/5

The parser's silent handling of malformed entity references needs to be fixed before merging because it can erase optional CSDL data.

The new attribute decoding path converts unescape errors to absence, so malformed values silently select defaults or drop annotations instead of preserving or reporting the input error.

crates/temper-spec/src/csdl/parser/xml.rs

Important Files Changed

Filename Overview
crates/temper-spec/src/csdl/emit.rs Routes all emitted string attributes through centralized XML escaping while preserving XML-normalized whitespace with character references.
crates/temper-spec/src/csdl/parser/xml.rs Decodes legal XML references, but converts decoding errors into absent attributes and thereby permits silent typed-model changes.
crates/temper-spec/src/csdl/emit_test.rs Moves existing emitter tests into a dedicated module and adds broad injection and whitespace round-trip regression coverage.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[CsdlDocument] --> B[emit_csdl_xml]
    B --> C[xml_escape all string attributes]
    C --> D[Well-formed CSDL XML]
    D --> E[parse_csdl]
    E --> F[attr_str unescapes references]
    F --> G[Round-tripped CsdlDocument]
Loading

Fix All in Claude Code Fix All in Codex Fix All in Cursor

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
crates/temper-spec/src/csdl/parser/xml.rs:51
**Malformed entities erase optional attributes**

When an optional attribute contains a malformed entity reference, converting `unescape_value()` errors to `None` makes the parser treat the present attribute as absent, causing annotations to be dropped or values such as nullability, defaults, paths, and targets to silently revert to fallback values.

Reviews (1): Last reviewed commit: "Escape every attribute value emitted in ..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

nerdsane and others added 2 commits July 22, 2026 00:22
`attr_str` returned `Attribute::value` verbatim, which is the raw,
still-escaped byte range. Any CSDL containing `&amp;`, `&quot;` or a
numeric character reference in an attribute therefore parsed into the
typed model as the literal escape text rather than the character it
denotes.

This is load-bearing for the emitter escaping that follows: with the
emitter escaping correctly and the parser not unescaping, every
parse/emit/parse cycle would add a layer (`&` -> `&amp;` -> `&amp;amp;`).
The two changes are only correct together.

No existing behaviour changes for the current corpus — no CSDL fixture
in the repository contains an entity reference — so this commit is a
no-op for the suite and is separated purely to keep the shared read-path
change reviewable on its own.

Refs ARN-237.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`emit_csdl_xml` applied `xml_escape` to only 4 of roughly 30
interpolation sites. Names, types, namespaces, DefaultValue,
navigation-binding Path/Target, action/function references and
annotation terms were all written into attributes verbatim. Those values
are agent- and user-influenced, so a `"` in an identifier closed the
attribute and let the remainder of the value inject arbitrary markup —
the same defect class as ARN-172. A property named

    Name"/><Property Name="Smuggled

produced a second, unintended property element, and a smuggled
`HasStream="true"` altered the typed model on re-parse.

Every interpolated string value now passes through `xml_escape`. The
remaining unescaped interpolations are numeric (`i64`, `u32`, `f64`,
`bool`) and cannot produce a metacharacter.

`xml_escape` additionally escapes tab, newline and carriage return as
character references. XML attribute-value normalisation replaces literal
occurrences with spaces, so without this a multi-line DefaultValue or
Description came back altered even when the quoting was correct. The
function is also now a single pass rather than five chained `replace`
calls.

Tests cover both properties: `adversarial_identifiers_do_not_inject_markup`
asserts that no live markup is emitted and that the typed model survives
a round trip unchanged, across entity types, keys, properties,
annotations, containers, entity sets, navigation bindings and
action/function imports; `whitespace_in_attribute_values_round_trips`
pins the normalisation behaviour.

This is the emitter half of ARN-237. The strict-parsing half — rejecting
truncated schemas, mandatory attributes, unknown-element rejection — is
deliberately deferred: it increases the set of persisted schemas that
fail to load, which interacts directly with ARN-190, where one corrupt
tenant CSDL aborts the entire registry restore.

Refs ARN-237.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@NALLSUR
NALLSUR force-pushed the surendranalla/arn-237-bug-csdl-parser-accepts-truncated-or-defaulted-schemas-and branch from 9e40ee1 to a5e8053 Compare July 22, 2026 21:57
@NALLSUR
NALLSUR marked this pull request as ready for review July 23, 2026 19:18
.flatten()
.find(|attribute| std::str::from_utf8(attribute.key.as_ref()).unwrap_or("") == name)
.and_then(|attribute| String::from_utf8(attribute.value.to_vec()).ok())
.and_then(|attribute| attribute.unescape_value().ok())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Malformed entities erase optional attributes

When an optional attribute contains a malformed entity reference, converting unescape_value() errors to None makes the parser treat the present attribute as absent, causing annotations to be dropped or values such as nullability, defaults, paths, and targets to silently revert to fallback values.

Context Used: CLAUDE.md (source)

Knowledge Base Used: Temper spec format, codegen, and verification

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-spec/src/csdl/parser/xml.rs
Line: 51

Comment:
**Malformed entities erase optional attributes**

When an optional attribute contains a malformed entity reference, converting `unescape_value()` errors to `None` makes the parser treat the present attribute as absent, causing annotations to be dropped or values such as nullability, defaults, paths, and targets to silently revert to fallback values.

**Context Used:** CLAUDE.md ([source](https://github.com/nerdsane/temper/blob/main/CLAUDE.md))

**Knowledge Base Used:** [Temper spec format, codegen, and verification](https://app.greptile.com/arni-labs/-/custom-context/knowledge-base/nerdsane/temper/-/docs/temper-spec.md)

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex Fix in Cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants