Skip to content

Commit 168443b

Browse files
committed
Normalize Fee extension XML tags in EPP response
Nomulus currently supports multiple versions of the Fee extensions. Our current tooling requires that each version must use a unique namespace tag, e.g., fee11, fee12, etc. Some client registrars are sensitive to the tag literal used by the version of the extension they use. For example, a few registrars currently using v0.6 have requested that the `fee` literal be used on the versions they currently use. With registrars upgrading at their own schedule, this kind of requests are impossible to satisfy. This PR instroduces a namespace normalizer class for EPP responses. The key optimization is that each EPP response never mixes multiple versions of a service extension. Therefore we can define a canonical tag for each extension, and change the tag of the extension in use in a response to that. This normalizer only handles Fee extensions right now, but the idea can be extended to others if use cases come up. This normalizer will be applied to all flows in a future PR.
1 parent 3f8145b commit 168443b

10 files changed

+742
-0
lines changed
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
// Copyright 2026 The Nomulus Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.package google.registry.flows;
14+
15+
package google.registry.flows;
16+
17+
import static com.google.common.collect.ImmutableList.toImmutableList;
18+
import static com.google.common.collect.Streams.stream;
19+
import static java.nio.charset.StandardCharsets.UTF_8;
20+
21+
import com.google.common.base.CharMatcher;
22+
import com.google.common.collect.ImmutableList;
23+
import com.google.common.collect.ImmutableSet;
24+
import com.google.common.collect.Iterables;
25+
import com.google.common.flogger.FluentLogger;
26+
import java.io.ByteArrayInputStream;
27+
import java.io.ByteArrayOutputStream;
28+
import java.io.UnsupportedEncodingException;
29+
import java.util.Base64;
30+
import java.util.Iterator;
31+
import java.util.Optional;
32+
import java.util.Set;
33+
import java.util.stream.Collectors;
34+
import javax.xml.namespace.QName;
35+
import javax.xml.stream.XMLEventFactory;
36+
import javax.xml.stream.XMLEventReader;
37+
import javax.xml.stream.XMLEventWriter;
38+
import javax.xml.stream.XMLInputFactory;
39+
import javax.xml.stream.XMLOutputFactory;
40+
import javax.xml.stream.XMLStreamException;
41+
import javax.xml.stream.events.Attribute;
42+
import javax.xml.stream.events.EndElement;
43+
import javax.xml.stream.events.Namespace;
44+
import javax.xml.stream.events.StartElement;
45+
import javax.xml.stream.events.XMLEvent;
46+
47+
/**
48+
* Normalizes Fee extension namespace tags in EPP XML response messages.
49+
*
50+
* <p>Nomulus currently supports multiple versions of the Fee extension. With the current XML
51+
* tooling, the namespace of every version is included in each EPP response, and as a result must
52+
* use a unique XML tag. E.g., fee for extension v0.6, and fee12 for extension v0.12.
53+
*
54+
* <p>Some registrars are not XML namespace-aware and rely on the XML tags being specific literals.
55+
* This makes it impossible to perform seamless rollout of new versions. Nomulus and the all
56+
* registrars using the same tag must make coordinated rollouts to minimize disruption to the
57+
* registrars.
58+
*
59+
* <p>This class can be used to normalize the namespace tag in EPP responses. Since every response
60+
* message may use at most one version of the Fee extension, we can remove declared but unused
61+
* versions from the message, thus freeing up the canonical tag ('fee') for the active version.
62+
*/
63+
public class FeeExtensionXmlTagNormalizer {
64+
65+
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
66+
67+
// So far we only have Fee extensions to process
68+
private static final String CANONICAL_FEE_TAG = "fee";
69+
private static final ImmutableSet FEE_EXTENSIONS =
70+
ImmutableSet.of(
71+
"urn:ietf:params:xml:ns:fee-0.6",
72+
"urn:ietf:params:xml:ns:fee-0.11",
73+
"urn:ietf:params:xml:ns:fee-0.12",
74+
"urn:ietf:params:xml:ns:epp:fee-1.0");
75+
76+
private static final XMLInputFactory XML_INPUT_FACTORY = createXmlInputFactory();
77+
private static final XMLOutputFactory XML_OUTPUT_FACTORY = XMLOutputFactory.newFactory();
78+
private static final XMLEventFactory XML_EVENT_FACTORY = XMLEventFactory.newFactory();
79+
80+
/**
81+
* Returns an EPP XML message with normalized Fee extension tags.
82+
*
83+
* <p>The output always begins with version and encoding declarations no matter if the input
84+
* includes them. If encoding is not declared by input, UTF-8 will be used according to XML
85+
* standard.
86+
*/
87+
public static String normalizeFeeExtensionTag(byte[] inputXmlBytes) {
88+
try {
89+
// Keep exactly one newline at end of sanitized string.
90+
return CharMatcher.whitespace().trimTrailingFrom(normalize(inputXmlBytes)) + "\n";
91+
} catch (XMLStreamException | UnsupportedEncodingException e) {
92+
logger.atWarning().withCause(e).log("Failed to sanitize EPP XML message.");
93+
return Base64.getMimeEncoder().encodeToString(inputXmlBytes);
94+
}
95+
}
96+
97+
private static String normalize(byte[] inputXmlBytes)
98+
throws XMLStreamException, UnsupportedEncodingException {
99+
ParseResults parseResults = findFeeExtensionInUse(inputXmlBytes);
100+
101+
if (parseResults.feeExtensionInUse.isEmpty()) {
102+
// Fee extension not present. Return as is.
103+
return new String(inputXmlBytes, UTF_8);
104+
}
105+
106+
ByteArrayOutputStream outputXmlBytes = new ByteArrayOutputStream();
107+
XMLEventWriter xmlEventWriter =
108+
XML_OUTPUT_FACTORY.createXMLEventWriter(outputXmlBytes, UTF_8.name());
109+
110+
for (XMLEvent event : parseResults.xmlEvents()) {
111+
xmlEventWriter.add(normalizeXmlEvent(event, parseResults.feeExtensionInUse));
112+
// Most standard Java StAX implementations omits the content between the XML header and the
113+
// root element. Add a "\n" between them to improve readability.
114+
if (event.isStartDocument()) {
115+
xmlEventWriter.add(XML_EVENT_FACTORY.createCharacters("\n"));
116+
}
117+
}
118+
119+
xmlEventWriter.flush();
120+
return outputXmlBytes.toString(UTF_8);
121+
}
122+
123+
/**
124+
* Holds intermediate results during XML processing.
125+
*
126+
* @param feeExtensionInUse The fee extension namespace URI in the EPP response, if found
127+
* @param xmlEvents The parsed XML objects found in a pass, saved for reuse
128+
*/
129+
private record ParseResults(
130+
Optional<String> feeExtensionInUse, ImmutableList<XMLEvent> xmlEvents) {}
131+
132+
/**
133+
* Makes one pass of the input XML and returns parsed data the Fee extension in use.
134+
*
135+
* <p>Each XML message should use at most one Fee extension. This method returns it if found. The
136+
* {@link XMLEvent} objects returned by the parser are also saved for reuse.
137+
*
138+
* @throws IllegalArgumentException if more than one Fee extension version is found
139+
*/
140+
private static ParseResults findFeeExtensionInUse(byte[] inputXmlBytes)
141+
throws XMLStreamException {
142+
XMLEventReader xmlEventReader =
143+
XML_INPUT_FACTORY.createXMLEventReader(new ByteArrayInputStream(inputXmlBytes));
144+
145+
ImmutableList.Builder<XMLEvent> eventBuffer = new ImmutableList.Builder<>();
146+
Optional<String> feeExtensionInUse = Optional.empty();
147+
148+
// Make one pass through the message to identify the Fee extension in use.
149+
while (xmlEventReader.hasNext()) {
150+
XMLEvent xmlEvent = xmlEventReader.nextEvent();
151+
Optional<String> eventFeeExtensionUri = getXmlEventFeeExtensionUri(xmlEvent);
152+
153+
if (feeExtensionInUse.isEmpty()) {
154+
feeExtensionInUse = eventFeeExtensionUri;
155+
} else if (eventFeeExtensionUri.isPresent()
156+
&& !feeExtensionInUse.equals(eventFeeExtensionUri)) {
157+
throw new IllegalArgumentException(
158+
String.format(
159+
"Expecting one Fee extension, found two: %s -- %s",
160+
feeExtensionInUse, eventFeeExtensionUri.get()));
161+
}
162+
eventBuffer.add(xmlEvent);
163+
}
164+
return new ParseResults(feeExtensionInUse, eventBuffer.build());
165+
}
166+
167+
private static XMLEvent normalizeXmlEvent(XMLEvent xmlEvent, Optional<String> feeExtensionInUse) {
168+
if (xmlEvent.isStartElement()) {
169+
return normalizeStartElement(xmlEvent.asStartElement(), feeExtensionInUse);
170+
} else if (xmlEvent.isEndElement()) {
171+
return normalizeEndElement(xmlEvent.asEndElement(), feeExtensionInUse);
172+
} else {
173+
return xmlEvent;
174+
}
175+
}
176+
177+
private static Optional<String> getXmlEventFeeExtensionUri(XMLEvent xmlEvent) {
178+
if (xmlEvent.isStartElement()) {
179+
return getFeeExtensionUri(xmlEvent.asStartElement());
180+
}
181+
if (xmlEvent.isEndElement()) {
182+
String extension = xmlEvent.asEndElement().getName().getNamespaceURI();
183+
if (FEE_EXTENSIONS.contains(extension)) {
184+
return Optional.of(extension);
185+
}
186+
}
187+
return Optional.empty();
188+
}
189+
190+
private static Optional<String> getFeeExtensionUri(StartElement startElement) {
191+
Set<String> attrs =
192+
stream(startElement.asStartElement().getAttributes())
193+
.map(Attribute::getName)
194+
.map(FeeExtensionXmlTagNormalizer::getFeeExtensionUri)
195+
.flatMap(Optional::stream)
196+
.collect(Collectors.toSet());
197+
var qName = startElement.asStartElement().getName();
198+
if (FEE_EXTENSIONS.contains(qName.getNamespaceURI())) {
199+
attrs.add(qName.getNamespaceURI());
200+
}
201+
if (attrs.size() > 1) {
202+
throw new IllegalArgumentException("Multiple Fee extension in use: " + attrs);
203+
}
204+
if (attrs.isEmpty()) {
205+
return Optional.empty();
206+
}
207+
// attrs.size == 1
208+
return Optional.of(Iterables.getOnlyElement(attrs));
209+
}
210+
211+
private static Optional<String> getFeeExtensionUri(QName name) {
212+
String extensionUri = name.getNamespaceURI();
213+
if (FEE_EXTENSIONS.contains(extensionUri)) {
214+
return Optional.of(extensionUri);
215+
}
216+
return Optional.empty();
217+
}
218+
219+
private static XMLEvent normalizeStartElement(
220+
StartElement startElement, Optional<String> feeExtensionInUse) {
221+
QName name = normalizeName(startElement.getName());
222+
ImmutableList<Namespace> namespaces =
223+
normalizeNamespaces(startElement.getNamespaces(), feeExtensionInUse);
224+
ImmutableList<Attribute> attributes = normalizeAttributes(startElement.getAttributes());
225+
226+
return XML_EVENT_FACTORY.createStartElement(name, attributes.iterator(), namespaces.iterator());
227+
}
228+
229+
private static XMLEvent normalizeEndElement(
230+
EndElement endElement, Optional<String> feeExtensionInUse) {
231+
QName name = normalizeName(endElement.getName());
232+
ImmutableList<Namespace> namespaces =
233+
normalizeNamespaces(endElement.getNamespaces(), feeExtensionInUse);
234+
235+
return XML_EVENT_FACTORY.createEndElement(name, namespaces.iterator());
236+
}
237+
238+
private static QName normalizeName(QName name) {
239+
if (!FEE_EXTENSIONS.contains(name.getNamespaceURI())
240+
|| name.getPrefix().equals(CANONICAL_FEE_TAG)) {
241+
return name;
242+
}
243+
return new QName(name.getNamespaceURI(), name.getLocalPart(), CANONICAL_FEE_TAG);
244+
}
245+
246+
private static Attribute normalizeAttribute(Attribute attribute) {
247+
QName name = normalizeName(attribute.getName());
248+
return XML_EVENT_FACTORY.createAttribute(name, attribute.getValue());
249+
}
250+
251+
private static Optional<Namespace> normalizeNamespace(
252+
Namespace namespace, Optional<String> feeExtensionInUse) {
253+
var extension = namespace.getNamespaceURI();
254+
if (!FEE_EXTENSIONS.contains(extension)) {
255+
return Optional.of(namespace);
256+
}
257+
if (feeExtensionInUse.isPresent() && extension.equals(feeExtensionInUse.get())) {
258+
if (namespace.getPrefix().equals(CANONICAL_FEE_TAG)) {
259+
return Optional.of(namespace);
260+
}
261+
return Optional.of(XML_EVENT_FACTORY.createNamespace(CANONICAL_FEE_TAG, extension));
262+
}
263+
return Optional.empty();
264+
}
265+
266+
private static ImmutableList<Attribute> normalizeAttributes(Iterator<Attribute> attributes) {
267+
return stream(attributes).map(attr -> normalizeAttribute(attr)).collect(toImmutableList());
268+
}
269+
270+
private static ImmutableList<Namespace> normalizeNamespaces(
271+
Iterator<Namespace> namespaces, Optional<String> feeExtensionInUse) {
272+
return stream(namespaces)
273+
.map(namespace -> normalizeNamespace(namespace, feeExtensionInUse))
274+
.flatMap(Optional::stream)
275+
.collect(toImmutableList());
276+
}
277+
278+
private static XMLInputFactory createXmlInputFactory() {
279+
XMLInputFactory xmlInputFactory = XMLInputFactory.newFactory();
280+
// Coalesce adjacent data, so that all chars in a string will be grouped as one item.
281+
xmlInputFactory.setProperty(XMLInputFactory.IS_COALESCING, true);
282+
// Preserve Name Space information.
283+
xmlInputFactory.setProperty(XMLInputFactory.IS_NAMESPACE_AWARE, true);
284+
// Prevent XXE attacks.
285+
xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
286+
xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
287+
return xmlInputFactory;
288+
}
289+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright 2026 The Nomulus Authors. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.package google.registry.flows;
14+
15+
package google.registry.flows;
16+
17+
import static com.google.common.truth.Truth.assertThat;
18+
import static google.registry.flows.FeeExtensionXmlTagNormalizer.normalizeFeeExtensionTag;
19+
import static google.registry.model.eppcommon.EppXmlTransformer.validateOutput;
20+
import static google.registry.testing.TestDataHelper.loadBytes;
21+
import static google.registry.testing.TestDataHelper.loadFile;
22+
import static java.nio.charset.StandardCharsets.UTF_8;
23+
24+
import java.util.stream.Stream;
25+
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.params.ParameterizedTest;
27+
import org.junit.jupiter.params.provider.Arguments;
28+
import org.junit.jupiter.params.provider.MethodSource;
29+
30+
class FeeExtensionXmlTagNormalizerTest {
31+
32+
@ParameterizedTest
33+
@MethodSource("provideTestCombinations")
34+
@SuppressWarnings("unused") // Parameter 'name' is part of test case name
35+
void success_withFeeExtension(String name, String inputXmlFilename, String expectedXmlFilename)
36+
throws Exception {
37+
byte[] xmlBytes = loadBytes(getClass(), inputXmlFilename).read();
38+
String normalized = normalizeFeeExtensionTag(xmlBytes);
39+
String expected = loadFile(getClass(), expectedXmlFilename);
40+
// Verify that expected xml is syntatically correct.
41+
validateOutput(expected);
42+
43+
assertThat(normalized).isEqualTo(expected);
44+
}
45+
46+
@Test
47+
void success_noFeeExtensions() throws Exception {
48+
byte[] xmlBytes = loadBytes(getClass(), "domain_create.xml").read();
49+
String normalized = normalizeFeeExtensionTag(xmlBytes);
50+
assertThat(normalized).isEqualTo(new String(xmlBytes, UTF_8));
51+
}
52+
53+
@SuppressWarnings("unused")
54+
static Stream<Arguments> provideTestCombinations() {
55+
return Stream.of(
56+
Arguments.of(
57+
"v06",
58+
"domain_check_fee_response_raw_v06.xml",
59+
"domain_check_fee_response_normalized_v06.xml"),
60+
Arguments.of(
61+
"v11",
62+
"domain_check_fee_response_raw_v11.xml",
63+
"domain_check_fee_response_normalized_v11.xml"),
64+
Arguments.of(
65+
"v12",
66+
"domain_check_fee_response_raw_v12.xml",
67+
"domain_check_fee_response_normalized_v12.xml"),
68+
Arguments.of(
69+
"stdv1",
70+
"domain_check_fee_response_raw_stdv1.xml",
71+
"domain_check_fee_response_normalized_stdv1.xml"));
72+
}
73+
}

0 commit comments

Comments
 (0)