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
8 changes: 8 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
Current (7.13.0)
Fixed: DTD validation of suite files was silently disabled: the SAX validation feature was probed under an "https" identifier that no parser recognizes, so setValidating(true) was never reached and violations went unreported. Validation is enabled again, with a new testng.xml.validation=off|warn|strict system property; the default "warn" reports violations without failing the run (Julien Herr)
Fixed: XmlSuite.toXml() dropped the "description" attribute of <include>, so regenerating a suite (testng-failed.xml, for instance) lost method descriptions (Julien Herr)
Fixed: XmlSuite.toXml() dropped a <selector-class> priority of -1 while the parser reads a missing priority as 0. Since a negative method-selector priority changes selector evaluation, serializing a suite and reading it back altered its behaviour (Julien Herr)
Fixed: The doctype written by XmlSuite.toXml() advertised testng-1.0.dtd although the parser always resolves testng-1.1.dtd (Julien Herr)
Fixed: XmlSuite.toXml() emitted two sibling <groups> elements for a suite that has suite-level groups, which the DTD allows only once, so TestNG's own output did not validate (Julien Herr)
Fixed: DTD violations were discarded for suite files pointing at their own copy or a mirror of the DTD rather than at testng.org, so those suites were never validated (Julien Herr)
New: GITHUB-3319: testng.xml now has an XSD, testng-1.1.xsd, shipped next to testng-1.1.dtd and mirroring it declaration for declaration, for the tools that cannot consume a DTD. The DTD stays authoritative for files carrying a doctype; a test validates the whole suite corpus under both schemas and fails when the two stop agreeing (Julien Herr)
New: Added round trip characterization tests covering every suite file of the test corpus, so that XML serialization can be refactored safely (Julien Herr)
New: Added OpenRewrite to the build with a hand-picked recipe list (see rewrite.yml), and applied it to the main sources (Julien Herr)
Fixed: Remove leftover dead JUnit code: the deprecated unused ConversionUtils and orphaned JUnit test samples, following the removal of JUnit execution support in 7.10.0 (Julien Herr)
Update: Dependency refresh: Guice 6.0.0, JCommander 2.0, snakeyaml 2.6, slf4j-api 2.0.18. Guice 7 and JCommander 3 were skipped: they require jakarta.inject and Java 17 respectively
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public final class RuntimeBehavior {
private static final String TEST_CLASSPATH = "testng.test.classpath";
private static final String SKIP_CALLER_CLS_LOADER = "skip.caller.clsLoader";
public static final String TESTNG_USE_UNSECURED_URL = "testng.dtd.http";
public static final String XML_VALIDATION_MODE = "testng.xml.validation";
public static final String SHOW_TESTNG_STACK_FRAMES = "testng.show.stack.frames";
private static final String MEMORY_FRIENDLY_MODE = "testng.memory.friendly";
public static final String STRICTLY_HONOUR_PARALLEL_MODE = "testng.strict.parallel";
Expand Down Expand Up @@ -52,6 +53,14 @@ public static boolean useSecuredUrlForDtd() {
return !Boolean.getBoolean(TESTNG_USE_UNSECURED_URL);
}

/**
* @return the raw value of {@value #XML_VALIDATION_MODE}, or {@code null} when unset. Interpreted
* by {@code org.testng.xml.XmlValidationMode}.
*/
public static String getXmlValidationMode() {
return System.getProperty(XML_VALIDATION_MODE);
}

public static boolean isMemoryFriendlyMode() {
return Boolean.parseBoolean(System.getProperty(MEMORY_FRIENDLY_MODE, "false"));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@
*/
class DefaultXmlWeaver implements IWeaveXml {
// TODO: move constants to XmlSuite?
/** The name of the TestNG DTD. */
private static final String TESTNG_DTD = "testng-1.0.dtd";
/**
* The name of the TestNG DTD. Must stay in sync with {@code Parser.TESTNG_DTD}, which is the
* version the reader resolves from the classpath. The two had drifted apart, so the emitted
* doctype advertised a schema that was never the one used to read the file back. They cannot
* share a constant: {@code Parser} lives in testng-core, which depends on this module.
*/
private static final String TESTNG_DTD = "testng-1.1.dtd";

private static final String HTTPS_TESTNG_DTD_URL = "https://testng.org/" + TESTNG_DTD;

Expand Down Expand Up @@ -99,23 +104,28 @@ public String asXml(XmlSuite xmlSuite) {
DEFAULT_ALLOW_RETURN_VALUES.toString());
xsb.push("suite", p);

List<String> included = xmlSuite.getIncludedGroups();
List<String> excluded = xmlSuite.getExcludedGroups();
if (hasElements(included) || hasElements(excluded)) {
xsb.push("groups");
xsb.push("run");
for (String g : included) {
xsb.addEmptyElement("include", "name", g);
}
for (String g : excluded) {
xsb.addEmptyElement("exclude", "name", g);
}
xsb.pop("run");
xsb.pop("groups");
}

if (xmlSuite.getGroups() != null) {
xsb.getStringBuffer().append(xmlSuite.getGroups().toXml(" "));
} else {
// Only synthesize a <groups> block when the suite has no XmlGroups of its own to write.
// getIncludedGroups()/getExcludedGroups() read through to that same XmlGroups, so emitting
// both produced two sibling <groups> elements -- which the DTD allows only once, making
// TestNG's own output invalid. When the groups come from a parent suite there is nothing
// else to write, and flattening them here is what keeps a generated suite self-contained.
List<String> included = xmlSuite.getIncludedGroups();
List<String> excluded = xmlSuite.getExcludedGroups();
if (hasElements(included) || hasElements(excluded)) {
xsb.push("groups");
xsb.push("run");
for (String g : included) {
xsb.addEmptyElement("include", "name", g);
}
for (String g : excluded) {
xsb.addEmptyElement("exclude", "name", g);
}
xsb.pop("run");
xsb.pop("groups");
}
}

XmlUtils.dumpParameters(xsb, xmlSuite.getParameters());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ public String toXml(String indent) {
XMLStringBuffer xsb = new XMLStringBuffer(indent);
Properties p = new Properties();
p.setProperty("name", getName());
if (m_description != null) {
p.setProperty("description", m_description);
}
List<Integer> invocationNumbers = getInvocationNumbers();
if (invocationNumbers != null && !invocationNumbers.isEmpty()) {
p.setProperty("invocation-numbers", XmlClass.listToString(invocationNumbers));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@

/** This class describes the tag <code>&lt;method-selector&gt;</code> in testng.xml. */
public class XmlMethodSelector {

/** The priority assumed when the {@code priority} attribute is absent from the suite file. */
public static final int DEFAULT_PRIORITY = 0;

// Either this:
private String m_className;
private int m_priority;
private int m_priority = DEFAULT_PRIORITY;

// Or that:
private XmlScript m_script;
Expand Down Expand Up @@ -56,7 +60,10 @@ public String toXml(String indent) {
if (null != m_className) {
Properties clsProp = new Properties();
clsProp.setProperty("name", getClassName());
if (getPriority() != -1) {
// Omit the value the parser falls back to when the attribute is absent, so that a
// round trip is lossless. A negative priority is meaningful (see RunInfo#includeMethod)
// and must therefore be written out.
if (getPriority() != DEFAULT_PRIORITY) {
clsProp.setProperty("priority", String.valueOf(getPriority()));
}
xsb.addEmptyElement("selector-class", clsProp);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,15 @@ enum Location {
private final String m_fileName;
private final boolean m_loadClasses;
private boolean m_validate = false;
private boolean m_doctypeDeclared = false;
private boolean m_hasWarn = false;

/**
* Resolved once per parse rather than per violation, so a malformed suite cannot re-read the
* system property -- and re-log the "unknown value" warning -- for every error it produces.
*/
private final XmlValidationMode m_validationMode = XmlValidationMode.current();

public TestNGContentHandler(String fileName, boolean loadClasses) {
m_fileName = fileName;
m_loadClasses = loadClasses;
Expand All @@ -128,6 +135,12 @@ public TestNGContentHandler(String fileName, boolean loadClasses) {
public InputSource resolveEntity(String publicId, String systemId)
throws SAXException, IOException {

// The document declares a doctype, whoever ends up providing it. Tracked separately from
// m_validate, which means "TestNG substituted its own copy of the DTD": gating error reporting
// on m_validate silently discarded every violation for suites pointing at their own DTD copy
// or at a corporate mirror.
m_doctypeDeclared = true;

if (skipConsideringSystemId(systemId)) {
m_validate = true;
InputStream is = loadDtdUsingClassLoader();
Expand Down Expand Up @@ -495,10 +508,8 @@ public void xmlSelectorClass(boolean start, Attributes attributes) {
if (start) {
m_currentSelector.setName(attributes.getValue("name"));
String priority = attributes.getValue("priority");
if (priority == null) {
priority = "0";
}
m_currentSelector.setPriority(Integer.parseInt(priority));
m_currentSelector.setPriority(
priority == null ? XmlMethodSelector.DEFAULT_PRIORITY : Integer.parseInt(priority));
}
}

Expand Down Expand Up @@ -781,8 +792,29 @@ public void endElement(String uri, String localName, String qName) {

@Override
public void error(SAXParseException e) throws SAXException {
if (m_validate) {
throw e;
if (!m_doctypeDeclared) {
// Without a doctype a validating parser only ever complains that no grammar was found, which
// would turn the existing "you should add a <!DOCTYPE>" hint into a hard failure.
return;
}
switch (m_validationMode) {
case STRICT:
throw e;
case WARN:
Logger.getLogger(TestNGContentHandler.class)
.warn(
"The suite file ["
+ m_fileName
+ "] does not conform to "
+ Parser.TESTNG_DTD
+ ": "
+ e.getMessage()
+ ". Run with [-D"
+ RuntimeBehavior.XML_VALIDATION_MODE
+ "=strict] to turn this into a failure.");
break;
case OFF:
break;
}
}

Expand Down
79 changes: 61 additions & 18 deletions testng-core/src/main/java/org/testng/xml/XMLParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,68 @@

public abstract class XMLParser<T> implements IFileParser<T> {

private static final SAXParser m_saxParser;
private static final AutoCloseableLock lock = new AutoCloseableLock();

static {
SAXParserFactory spf = loadSAXParserFactory();
private static SAXParser m_saxParser;

if (supportsValidation(spf)) {
spf.setNamespaceAware(true);
spf.setValidating(true);
}
/** The mode {@link #m_saxParser} was configured for, so a change of mode can be noticed. */
private static XmlValidationMode configuredFor;

SAXParser parser = null;
try {
parser = spf.newSAXParser();
} catch (ParserConfigurationException | SAXException e) {
Logger.getLogger(XMLParser.class).error(e.getMessage(), e);
/** Whether {@link #m_saxParser} was built with DTD validation enabled. */
private static boolean validating;

/**
* Whether the next parse will validate against the TestNG DTD. Exposed so that tests can tell
* "validation is off in this JVM" apart from "this file is valid", instead of inferring it from a
* parse that does not fail -- an inference that cannot be made.
*/
static boolean isValidating() {
try (AutoCloseableLock ignore = lock.lock()) {
parser();
return validating;
}
m_saxParser = parser;
}

private static final AutoCloseableLock lock = new AutoCloseableLock();

public void parse(InputStream is, DefaultHandler dh) throws SAXException, IOException {
try (AutoCloseableLock ignore = lock.lock()) {
m_saxParser.parse(is, dh);
SAXParser parser = parser();
if (parser == null) {
throw new TestNGException("No SAXParser could be configured to read suite files.");
}
parser.parse(is, dh);
}
}

/**
* The shared parser, rebuilt when the validation mode has changed since it was created. The
* parser is a singleton because it is expensive, but pinning it to the mode that happened to be
* set when this class was first loaded made {@code testng.xml.validation} silently ineffective
* for anything that sets it later -- the very failure mode this setting exists to fix.
*
* <p>Must be called while holding {@link #lock}.
*/
private static SAXParser parser() {
XmlValidationMode mode = XmlValidationMode.current();
if (m_saxParser != null && mode == configuredFor) {
return m_saxParser;
}
SAXParserFactory spf = loadSAXParserFactory();

// Namespace awareness is deliberately left off: DTD validation does not need it, suite files
// are not namespaced, and turning it on would make an unbound prefix fatal and an xmlns
// attribute a validity error -- neither of which has anything to do with validating a suite.
validating = mode.isValidating() && supportsValidation(spf);
spf.setValidating(validating);
try {
m_saxParser = spf.newSAXParser();
} catch (ParserConfigurationException | SAXException e) {
Logger.getLogger(XMLParser.class).error(e.getMessage(), e);
m_saxParser = null;
}
configuredFor = mode;
return m_saxParser;
}
Comment on lines +57 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

JAXP SAXParserFactory FEATURE_SECURE_PROCESSING billion laughs protection default

💡 Result:

In the Java Development Kit (JDK), the SAXParserFactory has the FEATURE_SECURE_PROCESSING (FSP) feature enabled by default [1][2][3]. When FSP is enabled—either by default or through explicit configuration—the JDK enforces various security limits to protect against common XML-based attacks, including the "billion laughs" (exponential entity expansion) attack [1][3][4]. Key aspects of this default behavior include: 1. Processing Limits: With FSP enabled, the XML parser adheres to specific security properties that limit resource consumption, such as entity expansion limits and maximum occurrences of elements [1][2][5]. These limits directly mitigate the risk of denial-of-service attacks like the billion laughs attack [3][4]. 2. Default Status: While FSP is true by default for SAX, DOM, and validation factories, the JDK differentiates between this "default enabled" state and "explicitly set" state in older versions [6]. However, modern JDK security guidelines emphasize that these parsers observe secure processing limits by default [3][4]. 3. External Connections: Enabling FSP by default provides a baseline of protection; however, it does not automatically disable external connections (such as external entity resolution) [1][2][5]. To fully restrict external access, you must explicitly configure properties such as accessExternalDTD and accessExternalSchema [1][2][4]. 4. Explicit Configuration: To ensure a consistent security posture, it is a recommended practice to explicitly set the feature in your application code [3]: SAXParserFactory spf = SAXParserFactory.newInstance; spf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repository files of interest =="
git ls-files | rg '(^|/)(XMLParser\.java|xml|pom\.xml|maven\.config|gradle\.properties|.*\.java$|.*\.md$)' | head -200

echo "== XMLParser outline =="
ast-grep outline testng-core/src/main/java/org/testng/xml/XMLParser.java 2>/dev/null || true

echo "== XMLParser relevant section =="
sed -n '1,160p' testng-core/src/main/java/org/testng/xml/XMLParser.java | cat -n

echo "== TestNGContentHandler outline/section candidates =="
for f in $(git ls-files | rg 'TestNGContentHandler\.java$'); do
  echo "--- $f ---"
  ast-grep outline "$f" 2>/dev/null || true
  sed -n '1,240p' "$f" | cat -n
done

echo "== XMLConstants / FEATURE_SECURE_PROCESSING references =="
rg -n "FEATURE_SECURE_PROCESSING|XMLConstants|setFeature|setValidating|accessExternalDTD|accessExternalSchema|loadSAXParserFactory|supportsValidation|m_saxParser|configuredFor" testng-core/src/main/java/org/testng/xml testng-core/src/main/java 2>/dev/null || true

echo "== build java config references =="
for f in $(git ls-files | rg '(^|/)(pom\.xml|build\.gradle|build\.gradle\.kts|gradle\.properties|settings\.gradle|Makefile|.*\.md$)$'); do
  if rg -q 'maven\.compiler|source>|target>|java\.version|compileOptions|sourceCompatibility|targetCompatibility|java|JDK|17|21|23|25' "$f" 2>/dev/null; then
    echo "--- $f ---"
    rg -n 'maven\.compiler|source>|target>|java\.version|compileOptions|sourceCompatibility|targetCompatibility|java|JDK|17|21|23|25' "$ f" 2>/dev/null || true
  fi
done 2>/dev/null

Repository: testng-team/testng

Length of output: 12957


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== targeted file list =="
git ls-files | grep -E '(^|/)XMLParser\.java$|(^|/)XmlUtils\.java$|^gradle\.properties$|(^|/)pom\.xml$' || true

echo "== XMLParser relevant section =="
if [ -f testng-core/src/main/java/org/testng/xml/XMLParser.java ]; then
  sed -n '1,180p' testng-core/src/main/java/org/testng/xml/XMLParser.java | cat -n
fi

echo "== TestNGContentHandler references =="
git ls-files | grep -E 'TestNGContentHandler\.java$' | while read -r f; do
  echo "--- $f ---"
  sed -n '1,280p' "$f" | cat -n
done

echo "== relevant XML/JAXP code references =="
rg -n "FEATURE_SECURE_PROCESSING|XMLConstants|setFeature|setValidating|accessExternalDTD|accessExternalSchema|loadSAXParserFactory|supportsValidation|m_saxParser|configuredFor|resolveEntity|EntityResolver|SAXParserFactory|newSAXParser" testng-core/src/main/java testng-core-api/src/main/java testng-*.gradle* pom.xml gradle.properties -S || true

Repository: testng-team/testng

Length of output: 28819


Secure the SAX factory before enabling DTD validation.

spf.newSAXParser() is created without XMLConstants.FEATURE_SECURE_PROCESSING; the EntityResolver only controls external DTD/entity fetching and does not protect against internal-entity-exansion attacks such as “billion laughs”. With DTD validation re-enabled, configure the SAX parser with secure processing, and disable external DTD access unless it is intentionally required.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 69-69: SAXParser created without secure processing is vulnerable to XXE
Context: spf.newSAXParser()
Note: [CWE-611] Improper Restriction of XML External Entity Reference.

(xml-parsing-xxe-saxparser)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@testng-core/src/main/java/org/testng/xml/XMLParser.java` around lines 57 -
77, Update parser() to configure the SAXParserFactory with
XMLConstants.FEATURE_SECURE_PROCESSING before enabling validation, and disable
external DTD access unless explicitly required. Apply these settings to spf
before spf.newSAXParser(), preserving the existing validation-mode behavior and
error handling.

Source: Linters/SAST tools


/**
* Tries to load a <code>SAXParserFactory</code> via <code>SAXParserFactory.newInstance()</code>.
*
Expand All @@ -57,12 +92,20 @@ private static SAXParserFactory loadSAXParserFactory() {
}
}

/** Tests if the current <code>SAXParserFactory</code> supports DTD validation. */
/**
* Tests if the current <code>SAXParserFactory</code> supports DTD validation.
*
* <p>The feature name is a plain identifier, not a URL to dereference, so it keeps its historical
* <code>http</code> scheme. Probing it under <code>https</code> makes every conforming parser
* raise <code>SAXNotRecognizedException</code>, which silently disabled validation altogether.
*/
private static boolean supportsValidation(SAXParserFactory spf) {
try {
spf.getFeature("https://xml.org/sax/features/validation");
spf.getFeature("http://xml.org/sax/features/validation");
return true;
} catch (Exception ex) {
Logger.getLogger(XMLParser.class)
.warn("The XML parser in use does not support DTD validation: " + ex);
return false;
}
}
Expand Down
69 changes: 69 additions & 0 deletions testng-core/src/main/java/org/testng/xml/XmlValidationMode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package org.testng.xml;

import java.util.Arrays;
import java.util.Locale;
import org.testng.internal.RuntimeBehavior;
import org.testng.log4testng.Logger;

/**
* How strictly a suite file is checked against the TestNG DTD, selected with the {@code
* testng.xml.validation} system property.
*
* <p>Validation used to be silently disabled: {@code XMLParser} probed the SAX validation feature
* under an {@code https} identifier, which no parser recognizes, so {@code setValidating(true)} was
* never reached and DTD violations went unreported. Turning it back on means suite files that have
* been accepted for years can suddenly be rejected -- the DTD constrains the order of the children
* of {@code <suite>}, for instance -- so {@link #WARN} is the default for now and reports
* violations without failing the run.
*
* <p>The property is read once per parse, never in the middle of one. {@code XMLParser} rebuilds
* its shared parser when the mode has changed since the last parse, which decides <em>whether</em>
* violations are raised at all, and {@code TestNGContentHandler} captures the mode when it is
* constructed, which decides <em>how</em> they are reported. Changing the property therefore takes
* effect from the next parse onwards, including when moving away from {@link #OFF}; changing it
* while a parse is in flight has no effect on that parse.
*/
public enum XmlValidationMode {

/** Do not validate at all. */
OFF,

/** Validate and report violations as warnings. The default. */
WARN,

/** Validate and fail on the first violation. */
STRICT;

private static final XmlValidationMode DEFAULT = WARN;

public boolean isValidating() {
return this != OFF;
}

/**
* The mode requested by the {@code testng.xml.validation} system property, falling back to {@link
* #WARN} when the property is absent or holds an unknown value.
*/
public static XmlValidationMode current() {
String requested = RuntimeBehavior.getXmlValidationMode();
if (requested == null || requested.trim().isEmpty()) {
return DEFAULT;
}
try {
return valueOf(requested.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
Logger.getLogger(XmlValidationMode.class)
.warn(
"Unknown value ["
+ requested
+ "] for the system property ["
+ RuntimeBehavior.XML_VALIDATION_MODE
+ "]. Expected one of "
+ Arrays.toString(values())
+ ". Falling back to ["
+ DEFAULT
+ "].");
return DEFAULT;
}
}
}
Loading
Loading