Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions release-notes/CREDITS
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,7 @@ Christian Beikov (@beikov)
* Fixed #915: Retain constraints and stream features in
`XmlFactory.readResolve()`
(3.1.7)
* Fixed #921: Escape colon in XML names written by `XmlNameProcessors` escaping
processors (`<a:b>` written for name "a:b" has an undeclared prefix, can not
be read back)
(3.3.0)
3 changes: 3 additions & 0 deletions release-notes/VERSION
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ Version: 3.x (for earlier see VERSION-2.x)
#913: `XmlMapper.Builder.defaultUseWrapper()` changes mapper that builder was
created from (via `rebuild()`), or has already built
(fix by @Sahana2524)
#921: Escape colon in XML names written by `XmlNameProcessors` escaping processors
(`<a:b>` written for name "a:b" has an undeclared prefix, can not be read back)
(fix by @Sahana2524)

3.2.3 (21-Sep-2026)

Expand Down
24 changes: 17 additions & 7 deletions src/main/java/tools/jackson/dataformat/xml/XmlNameProcessors.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ public static XmlNameProcessor newPassthroughProcessor() {
* </li>
* <li>Hyphen ({@code -}) in position OTHER than the first character
* </li>
* <li>Colon (only exposed if underlying parser is in non-namespace-aware mode)
* </li>
* </ul>
* in an
* XML name with a replacement string. This is a one-way processor, since
Expand All @@ -63,6 +61,10 @@ public static XmlNameProcessor newPassthroughProcessor() {
* <p>
* NOTE: this processor works for US-ASCII based element and attribute names
* but is unlikely to work well for many "international" use cases.
*<p>
* NOTE: a colon is replaced too. Names are written namespace-aware, so a colon
* in one is read back as a prefix that nothing declares, leaving output this
* module can not read.
*
* <pre>{@code
* <DTO>
Expand Down Expand Up @@ -99,8 +101,6 @@ public static XmlNameProcessor newReplacementProcessor() {
* </li>
* <li>Hyphen ({@code -}) in position OTHER than the first character
* </li>
* <li>Colon (only exposed if underlying parser is in non-namespace-aware mode)
* </li>
* </ul>
* with a base64-encoded version. Here the
* <a href="https://datatracker.ietf.org/doc/html/rfc4648#section-5">base64url</a>
Expand All @@ -122,6 +122,11 @@ public static XmlNameProcessor newReplacementProcessor() {
* NOTE: names that already start with {@code prefix} are escaped as well, even
* though they are otherwise valid, so that decoding cannot confuse them with
* names this processor encoded.
*<p>
* NOTE: a name containing a colon is escaped too. Names are written
* namespace-aware, so a colon in one is read back as a prefix that nothing
* declares, leaving output this module can not read; escaping keeps the colon
* in the decoded name instead.
*
* @param prefix The prefix to use for name that are escaped
*/
Expand Down Expand Up @@ -182,8 +187,10 @@ public void decodeName(XmlName name) { }
static class ReplaceNameProcessor implements XmlNameProcessor {
private static final long serialVersionUID = 1L;

private static final Pattern BEGIN_MATCHER = Pattern.compile("^[^a-zA-Z_:]");
private static final Pattern MAIN_MATCHER = Pattern.compile("[^a-zA-Z0-9_:-]");
// A colon counts as invalid: the writer is namespace-aware and declares no
// prefix for one, so a name carrying it comes out non-well-formed
private static final Pattern BEGIN_MATCHER = Pattern.compile("^[^a-zA-Z_]");
private static final Pattern MAIN_MATCHER = Pattern.compile("[^a-zA-Z0-9_-]");

private final String _replacement;

Expand Down Expand Up @@ -211,7 +218,10 @@ static class Base64NameProcessor implements XmlNameProcessor {

private static final Base64.Decoder BASE64_DECODER = Base64.getUrlDecoder();
private static final Base64.Encoder BASE64_ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Pattern VALID_XML_NAME = Pattern.compile("[a-zA-Z_:]([a-zA-Z0-9_:.-])*");
// Colon excluded on purpose: see newBase64Processor(String). Only affects
// encoding; decodeName() keys off the prefix, so names read from a
// non-namespace-aware parser are still passed through untouched.
private static final Pattern VALID_XML_NAME = Pattern.compile("[a-zA-Z_]([a-zA-Z0-9_.-])*");

private final String _prefix;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,48 @@ public void testReplace() throws Exception {
assertNotNull(reversed);
}

// A colon in a content-derived name (here a Map key) is read back as a
// namespace prefix, and nothing declares one: both escaping processors used to
// treat it as already-valid and write it through, so the document they produced
// could not be read again. Names with more than one colon do not even parse.
@Test
public void testColonInNameRoundTrips() throws Exception {
final String[] keys = new String[] {
"a:b", ":leading", "a:b:c", "xmlns:x", "xsi:nil"
};
final XmlMapper[] mappers = new XmlMapper[] {
XmlMapper.builder(xmlFactory(XmlNameProcessors.newBase64Processor())).build(),
XmlMapper.builder(xmlFactory(XmlNameProcessors.newAlwaysOnBase64Processor())).build(),
};

for (XmlMapper mapper : mappers) {
for (String key : keys) {
DTO dto = new DTO();
dto.badMap.put(key, "xyz");

final String res = mapper.writeValueAsString(dto);
assertEquals(dto, mapper.readValue(res, DTO.class),
"Failed round-trip of key '"+key+"', written as: "+res);
}
}
}

// Replacement is one-way so the key does not survive, but what is written still
// has to be readable (and free of the colon that made it unreadable).
@Test
public void testColonInNameReplaced() throws Exception {
DTO dto = new DTO();
dto.badMap.put("a:b", "xyz");

XmlMapper mapper = XmlMapper.builder(
xmlFactory(XmlNameProcessors.newReplacementProcessor())
).build();

final String res = mapper.writeValueAsString(dto);
assertTrue(res.contains("<a_b>xyz</a_b>"), res);
assertNotNull(mapper.readValue(res, DTO.class));
}

public static class BadVarNameDTO {
public int $someVar$ = 5;
}
Expand Down
Loading