Safeguard
Application Security•

Java Vulnerabilities: A Fix Playbook for Maven and Gradle

How to fix Java deserialization, XXE, injection and vulnerable library findings in Maven and Gradle, then prove the fix shipped in the artifact you deploy.

Safeguard Security Team
41 min read

To fix a Java vulnerability you need two moves, not one. First, change what resolves: pin the safe version with Maven dependencyManagement or a Gradle constraint, so the transitive copy moves too. Second, change the code path: add an ObjectInputFilter, harden the XML parser, or bind query parameters. Then verify both in the artifact you actually ship.

This playbook takes the four vulnerability classes Java teams meet most often (unsafe deserialization, XML external entities, injection and plain vulnerable libraries) and gives the fix path for each in Maven and in Gradle, with the commands that prove the fix landed. If you want the full catalogue of Java weakness types first, start with our reference list of Java vulnerability classes. This post assumes you already know which class you are dealing with and need to close it.

Key takeaways

  • Most Java findings sit in transitive dependencies. Bumping the direct dependency in pom.xml or build.gradle often does nothing, because the build tool still resolves the old transitive version.
  • Maven picks the nearest declaration in the tree. dependencyManagement overrides that rule for every transitive occurrence, which makes it the standard way to pin a safe version.
  • Gradle picks the highest requested version by default. A dependency constraint with a because reason is the cleanest way to raise a transitive version, and strictly is how you lower or cap one.
  • An upgrade alone rarely closes deserialization, XXE or injection. Those classes also need a code or configuration change: a serialization filter, secure parser features, or bound parameters.
  • Make the fix stick. Use the Maven Enforcer Plugin's bannedDependencies rule or Gradle dependency locking so a later merge cannot pull the vulnerable version back.
  • Verify against the built artifact, not the build file: rerun the dependency report, then inspect and rescan the jar, war or image.

The fix path at a glance

Every class below has a build side and a code side. The table is the short version; the sections after it give the commands and configuration.

Vulnerability classBuild fix (Maven)Build fix (Gradle)Code or config fixHow to verify
Vulnerable library (for example a logging or text library CVE)Bump direct dependency, or pin in dependencyManagementDependency constraint, or platform versionUsually none, unless the advisory names a mitigation flagdependency:tree / dependencyInsight, then scan the built artifact
Unsafe deserialization (Java serialization)Upgrade gadget libraries; ban known vulnerable versions with EnforcerConstraint plus lock fileObjectInputFilter (JEP 290, JEP 415), or remove native serializationUnit test that a disallowed class is rejected; filter logs
Unsafe deserialization (YAML, JSON, XML binders)Upgrade the binder (for example SnakeYAML 2.0 or later)Constraint with becauseSafe constructor or type allowlist; no default typingTest that unknown types fail to load
XML external entities (XXE)Upgrade the parser library if it is the vulnerable partSameDisable DOCTYPE or external entities on every factoryTest that a document with a DOCTYPE is rejected
Injection (SQL, JPQL, LDAP, expression)Upgrade the driver or ORM if the advisory is in the librarySameBind parameters; allowlist identifiersTests with hostile input; SAST rule passes

Step one: find where the vulnerable version comes from

You cannot fix a transitive vulnerability until you know which path brings it in. Both build tools print that path for you.

Maven: dependency:tree

The Maven Dependency Plugin's tree goal prints the resolved tree for the current project. Two options matter for security work: includes, which filters the output to the artifact you care about, and verbose, which also shows the versions Maven omitted because of conflicts.

# Show every path that brings in SnakeYAML
mvn dependency:tree -Dincludes=org.yaml:snakeyaml

# Include omitted and conflicting versions (useful when a pin seems ignored)
mvn dependency:tree -Dverbose -Dincludes=org.yaml:snakeyaml

Read the output from the bottom up. The last line is the vulnerable artifact; each indented level above it is the dependency that asked for it. That chain tells you whether you can fix it by upgrading a direct dependency, or whether you need to pin the transitive version.

In a multi module build, run it from the root: a vulnerable version can resolve in one module and not another, and the module that matters is the one producing the deployable artifact.

Gradle: dependencies and dependencyInsight

Gradle has two reporting tasks. dependencies prints the full tree per configuration. dependencyInsight answers the narrower question: why was this version of this module selected?

# Full runtime tree for one subproject
./gradlew :app:dependencies --configuration runtimeClasspath

# Why was this SnakeYAML version chosen, and who asked for it?
./gradlew :app:dependencyInsight --dependency org.yaml:snakeyaml --configuration runtimeClasspath

The insight report lists the selected version, the reason for selection (for example conflict resolution, a constraint, or a forced version) and every requester. When a version in your report is followed by an arrow, such as 1.33 -> 2.2, Gradle resolved the request to a different version than the one declared. That arrow is what you want to see after a fix.

Always pass the configuration you ship. For most JVM applications that is runtimeClasspath. A vulnerable library on testRuntimeClasspath only is still worth fixing, but it is not in your production artifact, and that changes its priority.

Why the build file alone misleads

A pom.xml or build.gradle shows what you asked for, not what you got. Parent POMs, imported BOMs, plugins and platforms all inject versions you will not see by reading the file. If a scanner and dependency:tree disagree, believe the one that looked at the resolved output.

How Maven chooses a version, and why your upgrade may not take

Maven's dependency mechanism uses "dependency mediation" to pick one version when the same artifact appears more than once in the tree. The rule, in the Maven documentation's words, is "nearest definition": the version closest to your project in the tree wins. If two declarations sit at the same depth, the first one declared wins.

This rule causes most of the confusion in Maven vulnerability work. If library A brings SnakeYAML 1.33 and library B brings 2.2, both two levels down, whichever you declared first decides the version. Upgrade B and nothing changes if A came first.

The Maven documentation states the fix directly: you can guarantee a version by declaring it explicitly in your POM, either as a direct dependency or through dependency management. The docs also describe the second mechanism: dependencyManagement "directly specif[ies] the versions of artifacts to be used when they are encountered in transitive dependencies or in dependencies where no version has been specified." That sentence is why dependencyManagement is the main tool for transitive fixes.

Maven fix patterns

Work through these in order. The first one that fully closes the finding is the one to use, because each later pattern adds maintenance.

Pattern 1: bump the direct dependency

If the vulnerable artifact is a direct dependency, or the direct dependency that pulls it in has a release that already uses the safe version, upgrade that and stop. This keeps your build honest: you are running the versions the library authors tested together.

<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-text</artifactId>
  <version>1.10.0</version>
</dependency>

Commons Text is a useful example. NVD's record for CVE-2022-42889 says releases 1.5 through 1.9 included default lookups (script, dns and url) that could lead to code execution or contact with remote servers when untrusted values are interpolated, and recommends upgrading to 1.10.0, which disables the problematic interpolators by default.

Pattern 2: pin a transitive version with dependencyManagement

When the vulnerable version arrives transitively and you cannot or do not want to upgrade the parent library, pin the safe version in dependencyManagement. Every occurrence of that artifact in the tree then resolves to your version.

<dependencyManagement>
  <dependencies>
    <!-- CVE-2022-1471: SnakeYAML before 2.0, unsafe Constructor -->
    <dependency>
      <groupId>org.yaml</groupId>
      <artifactId>snakeyaml</artifactId>
      <version>2.2</version>
    </dependency>
  </dependencies>
</dependencyManagement>

Leave a comment naming the CVE. Six months later someone will want to remove a pin that "looks unnecessary", and the comment is the only thing that tells them why it exists. Once the libraries above it ship the safe version themselves, remove the pin.

A pin has a compatibility cost: the parent library may never have been tested against your version. Patch releases are usually fine. For a major jump, as with SnakeYAML 1.x to 2.x, check the parent library's issue tracker and run your integration tests.

Pattern 3: import a BOM, and watch the order

A bill of materials (BOM) is a POM that only contains a dependencyManagement section. You import it with type pom and scope import, and its managed versions apply to your project as if you had written them yourself. Framework and library families publish BOMs so that all their modules move together.

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.apache.logging.log4j</groupId>
      <artifactId>log4j-bom</artifactId>
      <version>${log4j.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

Two things decide what wins. A version you declare directly in your own dependencyManagement section wins over one supplied by an imported BOM, so an explicit pin is always the dependable override. When two imported BOMs manage the same artifact, the result depends on how the imports are arranged, which is easy to get wrong when a framework BOM and a library BOM disagree. Rather than reason about import order during an incident, pin the artifact explicitly and confirm the result with dependency:tree. The Maven documentation also notes that dependency management takes precedence over dependency mediation for transitive dependencies.

The Maven documentation also notes that import scope is only supported on a dependency of type pom inside dependencyManagement. Putting a BOM in the ordinary dependencies section does not import its versions.

Pattern 4: override a version property from a parent

Many projects inherit from a framework parent POM that manages versions through properties. If the parent exposes a property for the vulnerable library, overriding it in your own properties block is the smallest possible change.

<properties>
  <!-- override the version the parent manages; name must match the parent's property -->
  <snakeyaml.version>2.2</snakeyaml.version>
</properties>

Check the property name in the parent's own POM before relying on it. If the name is wrong, Maven silently ignores your property and the old version stays. Confirm with mvn help:effective-pom or with dependency:tree, never by reading your own file.

Pattern 5: exclusions, when the dependency should not be there at all

Sometimes the right fix is removal. A library may drag in an optional module, an old XML parser or a logging backend you never use. An exclusion stops that artifact from entering your tree through that path.

<dependency>
  <groupId>com.example</groupId>
  <artifactId>legacy-client</artifactId>
  <version>3.4.1</version>
  <exclusions>
    <exclusion>
      <groupId>xerces</groupId>
      <artifactId>xercesImpl</artifactId>
    </exclusion>
  </exclusions>
</dependency>

Exclusions are per path: if another dependency also brings in the artifact, it stays. Rerun dependency:tree -Dincludes and your tests, because a missing class shows up as NoClassDefFoundError, often only on the code path that needed it.

Pattern 6: make it stick with the Maven Enforcer Plugin

A pin or exclusion can be undone by the next pull request. The Maven Enforcer Plugin turns your decision into a rule that fails the build. Three of its built-in rules matter here:

  • bannedDependencies fails the build if a matching artifact appears. Patterns follow groupId[:artifactId][:version][:type][:scope][:classifier], versions can be ranges, and the rule checks transitive dependencies unless you set searchTransitive to false.
  • requireUpperBoundDeps fails if a resolved version is lower than a version some other dependency asked for, which catches the "nearest wins, but nearest is older" problem.
  • dependencyConvergence fails if the tree contains more than one version of the same artifact, which forces every conflict to be settled explicitly.
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-enforcer-plugin</artifactId>
  <version>${enforcer.version}</version>
  <executions>
    <execution>
      <id>ban-vulnerable-versions</id>
      <goals><goal>enforce</goal></goals>
      <configuration>
        <rules>
          <bannedDependencies>
            <excludes>
              <!-- CVE-2021-44228, CVE-2021-44832: keep vulnerable log4j-core out -->
              <exclude>org.apache.logging.log4j:log4j-core:[2.0-beta7,2.17.1)</exclude>
              <!-- CVE-2022-1471 -->
              <exclude>org.yaml:snakeyaml:[,2.0)</exclude>
              <!-- CVE-2022-42889 -->
              <exclude>org.apache.commons:commons-text:[1.5,1.10.0)</exclude>
            </excludes>
          </bannedDependencies>
          <requireUpperBoundDeps/>
        </rules>
      </configuration>
    </execution>
  </executions>
</plugin>

Start dependencyConvergence in a report-only mode on a large legacy build, or it will fail on dozens of harmless conflicts. Note that the log4j-core range above also bans the 2.12.4 and 2.3.2 backport releases, which NVD lists as fixed; narrow the range if a service is pinned to one of those older lines. bannedDependencies with explicit vulnerable ranges is the rule to switch on first, because it fails only on the versions you have decided must never ship.

Pattern 7: find safe versions with the Versions Maven Plugin

The Versions Maven Plugin (from the MojoHaus project) reports and edits versions for you. The goals that help most in vulnerability work:

# What newer versions exist for my dependencies?
mvn versions:display-dependency-updates

# Same, for dependencies managed through properties
mvn versions:display-property-updates

# Set one dependency to a specific version across the POM
mvn versions:use-dep-version -Dincludes=org.yaml:snakeyaml -DdepVersion=2.2 -DforceVersion=true

# Update version properties to the latest available
mvn versions:update-properties -Dincludes=org.yaml:snakeyaml

Treat "latest" as a suggestion. The goal is the smallest version that closes the advisory and passes your tests; a jump to a new major release can turn a one line pin into a migration.

How Gradle chooses a version

Gradle's default conflict resolution differs from Maven's. The Gradle documentation says it considers all requested versions across the dependency graph and, by default, selects the highest version, not the nearest one. That means raising a transitive version is often as easy as declaring a higher one anywhere in the graph. Lowering or capping a version is the harder direction, and that is what rich versions such as strictly are for.

A dependency constraint takes part in this resolution like any other requirement. When a direct dependency and a constraint both apply, Gradle takes the intersection of their accepted versions and resolves to the highest version inside it.

Gradle fix patterns

Pattern 1: dependency constraints with a reason

Constraints are Gradle's equivalent of Maven's dependencyManagement for transitive pins. Add one inside the dependencies block, against the configuration that needs it, and record the reason with because.

// build.gradle.kts
dependencies {
    implementation("com.example:legacy-client:3.4.1")

    constraints {
        implementation("org.yaml:snakeyaml:2.2") {
            because("CVE-2022-1471: SnakeYAML before 2.0 instantiates arbitrary types")
        }
        implementation("org.apache.commons:commons-text:1.10.0") {
            because("CVE-2022-42889: default interpolators in 1.5 to 1.9")
        }
    }
}

The because text shows up in dependencyInsight output, which means the next person who asks "why is this version here?" gets the CVE number from the build tool itself. If you publish a library, the Gradle documentation notes that dependency constraints are only published through Gradle Module Metadata: consumers who build with Gradle inherit your safer floor, while Maven or Ivy consumers may not, so tell them about the pin in your release notes.

Pattern 2: rich versions, strictly and reject

Gradle supports richer version declarations than a single number. Two are useful for security:

  • strictly is the strongest declaration: any version outside it is excluded. The Gradle documentation says that on a declared dependency, strictly can downgrade a version, and that for transitive dependencies resolution fails if no acceptable version is found.
  • reject refuses specific versions or ranges outright, and appears in dependencyInsight as a selection reason.

Gradle's own documentation on rich versions uses a security fix as its example: a strict range inside a constraints block that keeps log4j-core at 2.17.1 or later, with a because that names CVE-2021-44832. In Kotlin DSL that looks like this:

dependencies {
    constraints {
        implementation("org.apache.logging.log4j:log4j-core") {
            version {
                strictly("[2.17.1, 2.18[")
            }
            because("CVE-2021-44832: versions prior to 2.17.1 are vulnerable")
        }
    }
}

Here strictly sets a floor rather than a downgrade: any transitive request below 2.17.1 is overridden. The trade off is that a strict upper bound must be raised by hand when you want 2.18 or later, so review strict ranges whenever you upgrade the family. Gradle's dependency resolution page adds one more rule: if a strictly version is lower than the highest version requested elsewhere, resolution fails instead of silently picking one.

Pattern 3: platforms and enforced platforms

A Gradle platform is the counterpart of a Maven BOM. platform(...) imports the BOM's versions as recommendations that take part in normal conflict resolution. enforcedPlatform(...) makes the BOM's versions win over everything else in the graph, including higher versions requested elsewhere.

dependencies {
    implementation(platform("org.apache.logging.log4j:log4j-bom:2.17.1"))
    implementation("org.apache.logging.log4j:log4j-core")   // version comes from the platform
}

Prefer platform in libraries and in most applications. Because enforcedPlatform overrides any other version in the graph, it can downgrade a dependency that another library needs, and in a published library it imposes those versions on every consumer. In an application it is a blunt instrument that is occasionally the right one.

You can also build your own platform as a subproject with the java-platform plugin, holding your organisation's security floors. Every application then imports that one platform, and a single change moves every service.

Pattern 4: resolutionStrategy for the cases constraints cannot reach

resolutionStrategy on a configuration gives you lower level control. Gradle's documentation describes resolution rules as brute force solutions that may mask underlying issues and advises using them only when other approaches are insufficient, so reach for them when a constraint is not enough.

configurations.configureEach {
    resolutionStrategy {
        // Fail on any version conflict instead of silently picking one
        // failOnVersionConflict()

        eachDependency {
            if (requested.group == "org.yaml" && requested.name == "snakeyaml"
                && requested.version?.startsWith("1.") == true) {
                useVersion("2.2")
                because("CVE-2022-1471")
            }
        }

        dependencySubstitution {
            // Replace an abandoned module with its maintained successor
            substitute(module("com.example:old-xml-utils"))
                .using(module("com.example:xml-utils:4.1.0"))
                .because("Relocated; the old coordinates receive no fixes")
        }
    }
}

eachDependency rewrites matching requests, so it can force a version even when no direct declaration exists. dependencySubstitution replaces one module with another, which helps when a library moved to new coordinates and the old ones will never get a fix. failOnVersionConflict() fails the build on any conflict; like Maven's dependencyConvergence, introduce it gradually on large builds.

Gradle also lets you force a version on a resolution strategy. Its constraints documentation says to prefer strictly to express version intent, because it is published in Gradle Module Metadata and takes part in conflict resolution, and to reserve force and resolve rules for local, last resort overrides.

Pattern 5: dependency locking

Constraints decide what versions are allowed. Lock files record which versions were actually resolved, so a build next week produces the same classpath as today. Gradle's dependency locking writes a gradle.lockfile per project.

// build.gradle.kts
dependencyLocking {
    lockAllConfigurations()
}
# Create or refresh lock state for every locked configuration
./gradlew dependencies --write-locks

# Update only the modules you are fixing, leave everything else locked
./gradlew dependencies --update-locks org.yaml:snakeyaml

Commit the lock file. Gradle adds locked versions to resolution as strictly constraints, which pin transitive versions just as a hand written strict declaration would, and a build that cannot satisfy the lock state fails. Setting lockMode = LockMode.STRICT also fails the build when a locked configuration has no lock state at all. Together, these mean a vulnerable version cannot drift back in through a dynamic version or a new transitive release. The --update-locks form is the one to use for security fixes, because it changes only the modules you name, so the pull request diff shows the vulnerable version leaving and nothing else moving.

Maven has no built-in equivalent of a lock file. The usual substitutes are pinning every version (no ranges, no LATEST), enforcing with requireUpperBoundDeps and bannedDependencies, and generating an SBOM per build so you can compare resolved versions between releases.

Pattern 6: version catalogs

A version catalog (gradle/libs.versions.toml) centralises the versions your build files reference, which makes a security upgrade a single line change across a multi project build. The Gradle documentation is clear about its limit: catalogs declare requested versions but do not enforce them, and Gradle may still select a different version because of graph conflicts or platform constraints. Pair a catalog with constraints or a platform, and check the result with dependencyInsight.

[versions]
snakeyaml = "2.2"

[libraries]
snakeyaml = { module = "org.yaml:snakeyaml", version.ref = "snakeyaml" }

Playbook by vulnerability class

The build patterns above move versions. The classes below show where that is enough and where you also need to change code.

Class 1: a vulnerable library

This is the most common Java finding and the one where the build fix usually is the whole fix. The workflow:

  1. Read the advisory and note the exact affected range and the first fixed version for the artifact named in it. For CVE-2021-44228, NVD's description names Log4j2 2.0-beta9 through 2.15.0, excluding the security releases 2.12.2, 2.12.3 and 2.3.1, and notes the issue is specific to log4j-core. A follow-up, CVE-2021-44832, affected 2.0-beta7 through 2.17.0 (excluding 2.3.2 and 2.12.4) when a configuration uses a JDBC Appender with a JNDI LDAP data source, and NVD lists 2.17.1, 2.12.4 and 2.3.2 as fixed. The safe floor moved twice in a month, so read the newest advisory for the artifact before you pick a version.
  2. Find every path to the artifact (dependency:tree -Dincludes or dependencyInsight).
  3. Pick the least invasive pattern: direct bump, then BOM or platform version, then dependencyManagement or a constraint.
  4. Add a ban (Enforcer bannedDependencies or a Gradle reject) so it cannot return.
  5. Rebuild, rerun the report, rescan the artifact.

Watch the artifact name. NVD notes CVE-2021-44228 is specific to log4j-core, so match on the exact groupId and artifactId in the advisory, not on a substring such as "log4j" that also hits unaffected modules.

Also watch for shaded copies. Some libraries copy a dependency into their own jar under a relocated package name, where Maven and Gradle cannot see it, so pinning the artifact does nothing. Upgrade the library that shades it, and use a scanner that inspects the jar contents to find these.

Class 2: unsafe deserialization

Deserialization vulnerabilities in Java come in two shapes, and the fix is different for each.

Native Java serialization. When an application calls ObjectInputStream.readObject() on untrusted bytes, any serializable class on the classpath can be instantiated, and some library classes ("gadgets") do dangerous things when they are. The best known case is Apache Commons Collections: NVD's record for CVE-2015-7501 says a list of Red Hat JBoss middleware products allowed remote attackers to execute arbitrary commands through a crafted serialized Java object, related to the Apache Commons Collections library.

Upgrading the gadget library helps, but the root cause is reading untrusted serialized data at all. The JDK's answer is serialization filtering. JEP 290, targeted at JDK 9, added ObjectInputFilter, which lets an application accept or reject classes, and cap array sizes, graph depth, references and stream size, before objects are created. JEP 415 (Java 17) added context specific filters, through a filter factory that can choose a filter per stream.

A process wide filter can be set with the jdk.serialFilter system property or in the java.security file:

java -Djdk.serialFilter='maxdepth=20;maxrefs=1000;maxbytes=500000;com.example.dto.*;java.base/*;!*' -jar app.jar

The pattern is read left to right: limits first, then allowed packages, and the final !* rejects everything not explicitly allowed. An allowlist is the right shape. A blocklist of known gadget classes always lags behind new gadget research.

A filter can also be applied to one stream in code:

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "maxdepth=20;maxrefs=1000;com.example.dto.*;java.base/*;!*");

try (ObjectInputStream in = new ObjectInputStream(input)) {
    in.setObjectInputFilter(filter);
    Object value = in.readObject();
}

On Java 17 and later, ObjectInputFilter.Config.setSerialFilterFactory lets you install a factory that combines the process wide filter with a per stream one, which is how you apply strict rules to the stream that reads network input without breaking a trusted internal cache that uses serialization too.

The OWASP Deserialization Cheat Sheet points the same way. Its Java advice is to configure serialization filters with an application specific class allowlist and resource limits before reading objects, and where the application knows in advance which messages it will process, to deserialize only signed data.

Data binding libraries (YAML, JSON, XML). Several binders can instantiate arbitrary types when asked to, and some do so by default. NVD's record for CVE-2022-1471 says SnakeYAML's Constructor() class does not restrict the types it instantiates, and recommends using SafeConstructor for untrusted content and upgrading to 2.0 or later. The fix therefore has a build half (move to SnakeYAML 2.0 or later) and a code half (load untrusted YAML only with a safe constructor or into a specific target type).

LoaderOptions options = new LoaderOptions();
Yaml yaml = new Yaml(new SafeConstructor(options));
Map<String, Object> config = yaml.load(untrustedInput);

For JSON binders, bind into concrete types and do not enable polymorphic or "default" typing on untrusted input; if you need polymorphism, allowlist the subtypes.

How to verify a deserialization fix. Write a test that feeds a serialized object of a class outside your allowlist and assert that readObject throws instead of returning it. For YAML, assert that a document with a global tag naming an arbitrary class fails to load. In a test environment, Oracle's serialization filtering guide shows how to log filter decisions to the java.io.serialization logger: set java.io.serialization.level = FINE in the JDK's logging.properties to log rejected classes, or FINEST to log every decision.

Class 3: XML external entities (XXE)

XXE happens when an XML parser resolves a DOCTYPE that defines external entities, letting a document make the server read local files or send requests to other hosts. In Java, most XXE findings are not library version bugs. They are default parser settings, which means upgrading rarely fixes them and code changes nearly always do.

The OWASP XML External Entity Prevention Cheat Sheet's guidance for DOM parsing is to reject DOCTYPE declarations and leave XInclude disabled. If your documents never use a DOCTYPE, and most API payloads do not, that is the simplest and strongest setting.

DocumentBuilderFactory dbf = DocumentBuilderFactory.newDefaultInstance();
// Reject any document that contains a DOCTYPE
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
DocumentBuilder builder = dbf.newDocumentBuilder();

The cheat sheet also warns about a setting many older code bases rely on: setExpandEntityReferences(false) only controls how entity references are represented in the tree, and must not be used as a substitute for blocking external resources. If your application genuinely needs DTDs, the cheat sheet says to disable external entity resolution and external DTD loading, and limit entity expansion, instead. Every factory type needs its own setting:

Parser APISetting that blocks XXE (per OWASP cheat sheet)
DocumentBuilderFactory (DOM)disallow-doctype-decl true plus FEATURE_SECURE_PROCESSING; leave XInclude off
SAXParserFactory and XMLReaderSame features, set on the factory or reader
XMLInputFactory (StAX)SUPPORT_DTD false and IS_SUPPORTING_EXTERNAL_ENTITIES false (StAX does not support FEATURE_SECURE_PROCESSING)
TransformerFactoryXMLConstants.ACCESS_EXTERNAL_DTD and ACCESS_EXTERNAL_STYLESHEET set to an empty string
SchemaFactory and ValidatorXMLConstants.ACCESS_EXTERNAL_DTD and ACCESS_EXTERNAL_SCHEMA set to an empty string
XPathEvaluate against a document parsed with a hardened builder
Unmarshaller (JAXB)Supply a hardened StAX reader and unmarshal from it
XMLInputFactory xif = XMLInputFactory.newDefaultFactory();
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);

TransformerFactory tf = TransformerFactory.newInstance();
tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");

Where the build matters for XXE. Two cases bring Maven and Gradle back in. First, the setting you configure applies to whichever implementation the factory loads. If an old XML parser jar is on the classpath, DocumentBuilderFactory.newInstance() may return that implementation, with its own defaults and its own bugs. Use dependency:tree -Dincludes=xerces or dependencyInsight --dependency xercesImpl to see whether one is present, and exclude it if the JDK's built-in parser will do. Second, some XXE CVEs really are in libraries (document converters, SOAP stacks, office file readers). For those, the vulnerable library pattern above applies.

How to verify an XXE fix. Keep a small test fixture: a harmless document with a DOCTYPE that declares an external entity pointing at a file inside your test resources. Assert that parsing throws. Run that test against every factory your code creates, not just the one you fixed, because one hardened DocumentBuilderFactory does not protect a TransformerFactory elsewhere in the code base. Our longer guide on XXE prevention walks through each parser in more depth.

To keep it fixed, route every module through one factory helper class and add a static analysis rule that flags direct factory calls outside it.

Class 4: injection (SQL, JPQL, LDAP, expression languages)

Injection is almost always a code fix. The OWASP Query Parameterization Cheat Sheet gives the core rule: keep data and code apart by binding user input as parameters, never by concatenating it into the query string.

// Vulnerable shape: input concatenated into SQL
// String sql = "SELECT * FROM accounts WHERE owner = '" + owner + "'";

String sql = "SELECT id, balance FROM accounts WHERE owner = ?";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
    ps.setString(1, owner);
    try (ResultSet rs = ps.executeQuery()) {
        // ...
    }
}

The same rule holds one layer up. With JPA, use named or positional parameters; JPQL built by string concatenation is just as injectable as SQL.

TypedQuery<Account> q = em.createQuery(
    "SELECT a FROM Account a WHERE a.owner = :owner", Account.class);
q.setParameter("owner", owner);

Parameters cannot bind identifiers such as table names, column names or sort directions. When users choose those, map their choice to a fixed allowlist in code and never pass the raw value through.

LDAP queries and expression languages (Spring Expression Language, OGNL, template engines) need the same discipline in their own form: escape or bind values with the library's own API, and never evaluate an expression built from user input. Commons Text's CVE-2022-42889, covered above, is an example of a library feature turning string interpolation into code execution when fed untrusted values, which is why its fix needed both a version change and a review of where interpolation was used.

Where the build matters for injection. JDBC drivers, ORMs and query builders occasionally have their own injection CVEs, in how they escape values or build SQL for special types. Those are vulnerable library findings, fixed with the build patterns above. But an upgrade never fixes injection in your own string building, so a scanner reporting "no vulnerable ORM version" says nothing about your queries. That half needs static analysis or code review.

How to verify an injection fix. Add tests with hostile input (quotes, comment markers, boolean tautologies) and assert that the query returns only the expected rows or fails safely. Static analysis rules for string concatenation into Statement.execute, createQuery or createNativeQuery should pass with no suppressions on the fixed code.

Verify the fix in what you ship

A fix is done when the deployed artifact no longer contains the vulnerable code, not when the pull request merges. Five checks, in order:

  1. Resolution. Rerun mvn dependency:tree -Dincludes=... or ./gradlew dependencyInsight --dependency ... for the shipped configuration. The vulnerable version must not appear anywhere.
  2. Enforcement. Run mvn validate and confirm the ban rule is active, for example by requesting a banned version on a scratch branch and watching the build fail.
  3. Packaging. Look inside the artifact. For an executable Spring Boot style jar, nested libraries sit under BOOT-INF/lib; for a war, under WEB-INF/lib. A command such as jar tf app.jar | grep snakeyaml shows exactly which version was packaged. Shaded and fat jars are where pins most often fail to take.
  4. Image. If you ship a container, scan the image, not just the repository. Base images and application server images bring their own Java libraries that your build never saw.
  5. Runtime. For deserialization and XXE, run the negative tests described above in CI so the code half of the fix is checked on every build, not just once.

Then rescan with the tool that raised the finding and close it with a note naming the fixing commit.

A CI job that keeps the fix in place

# .github/workflows/deps.yml (illustrative)
name: dependency-guard
on: [pull_request]
jobs:
  maven:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21' }
      - run: mvn -B -q validate          # Enforcer bans run here
      - run: mvn -B dependency:tree -Dincludes=org.yaml:snakeyaml
      - run: mvn -B verify               # unit tests, including XXE and filter tests
  gradle:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21' }
      - run: ./gradlew dependencies --configuration runtimeClasspath   # fails on a lock state violation when locking is enabled
      - run: ./gradlew check

Keep security tests in the normal suite; a separate job that teams can skip will be skipped.

Which mechanism to use

SituationMavenGradle
Vulnerable artifact is a direct dependencyBump the versionBump the version
Parent library has a release with the safe transitive versionUpgrade the parentUpgrade the parent
Need to raise one transitive version nowdependencyManagement pin with a CVE commentConstraint with because
Need to cap or downgrade a transitive versiondependencyManagement pinstrictly in a constraint
A whole library family must move togetherImport its BOM, and pin explicitly if another BOM disagreesplatform(...) with the BOM version
Framework parent manages the versionOverride the parent's version propertyOverride through the framework's platform or a constraint
The dependency is not needed at allExclusion on each pathexclude on the dependency or configuration
Library moved to new coordinatesReplace the dependency and exclude the old onedependencySubstitution
Stop the version coming backEnforcer bannedDependenciesreject plus dependency locking
Make every conflict visibleEnforcer dependencyConvergencefailOnVersionConflict()
Library shades the vulnerable codeUpgrade the shading libraryUpgrade the shading library

Ranking what to fix first

Not every open finding deserves the same urgency. Order the queue by exploitation evidence (a CVE in CISA's Known Exploited Vulnerabilities catalog, or a high EPSS score), by reachability (is the vulnerable method actually called from your code?) and by exposure (an internet facing API outranks an internal batch job). Our post on reachable versus present Java vulnerabilities covers how call graph analysis decides reachability and where reflection, dynamic proxies and dependency injection make it harder.

Reachability ranks; it does not dismiss. An unreachable finding becomes reachable when someone adds a feature, so keep it in the backlog at a lower priority. And the code half of each fix in this post (filters, parser settings, bound parameters) is worth doing whatever the ranking says, because it also protects against the next CVE in the same class.

Where Safeguard fits

Safeguard's software composition analysis resolves the complete dependency graph from your manifests and lockfiles, including pom.xml, build.gradle and lockfiles, so transitive Java dependencies are scanned along with direct ones. Each finding carries the vulnerable component and version, the full path from your project root to the vulnerable package, the reachability verdict where one is available and the safe upgrade target. That path is what dependency:tree would show you, attached to the finding, so you can see at once whether a direct bump or a pin is the right fix. The docs also describe the deep dependency view showing what other transitive changes the safe version would introduce, which is the compatibility question this post keeps returning to.

For prioritisation, Safeguard combines reachability with exploitability (EPSS and CISA KEV) and with runtime and business context to rank findings. Its documentation describes static reachability from source, lockfile and bytecode, and configuration reachability that reads framework configuration such as Spring bean definitions to tell whether code is wired in at all. It classifies each finding as reachable, conditionally reachable, unreachable or unknown, and treats unknown (for example reflection) conservatively as potentially reachable. How much this narrows your backlog depends on your code base, so measure it on your own services during an evaluation.

To close findings, AI Remediate uses Griffin, Safeguard's security model, to propose the upgrade. The docs list Maven and Gradle among supported ecosystems and describe it upgrading vulnerable packages to fixed versions, handling transitive dependency conflicts and updating lock files, with a breaking change assessment (safe, minor, major or critical) on each pull request. You can fix one vulnerability or several at once, with one pull request per repository. Review each pull request with the verification steps above.

What Safeguard does not do is remove the code changes in this post. A scanner can tell you SnakeYAML 1.33 is present and reachable; it cannot decide which classes your application should be allowed to deserialize. Keep the filters, parser hardening and parameter binding in your own code and tests. Our guide to choosing a Java vulnerability scanner covers what to compare between tools.

Common mistakes

  • Upgrading the direct dependency and assuming the transitive one followed. Under Maven's nearest wins rule, it often did not. Always rerun the tree.
  • Putting a BOM in the dependencies section. Import scope only works inside dependencyManagement with type pom. Outside it, the BOM's versions are ignored.
  • Relying on BOM import order. When a framework BOM and a library BOM manage the same artifact, it is easy to end up with the framework's older version. Pin the artifact explicitly in your own dependencyManagement and check the tree.
  • Misspelling a parent's version property. Maven ignores unknown properties without complaint. Confirm with help:effective-pom.
  • Matching on a substring. Searching for "log4j" finds unaffected bridge modules and misses nothing useful. Match the exact groupId and artifactId named in the advisory.
  • Ignoring shaded copies. A relocated copy inside another jar does not appear in the dependency tree. Upgrade the library that shades it.
  • Blocklisting gadget classes. Deserialization filters should allowlist what you expect and reject the rest. Blocklists fall behind.
  • Treating an ORM upgrade as an injection fix. It fixes the ORM's bugs, not your string concatenation.
  • Verifying the repository, shipping the image. The container base image and application server can bring their own Java libraries. Scan what you deploy.

FAQ

How do I fix a vulnerable transitive dependency in Maven?

Find the path with mvn dependency:tree -Dincludes=group:artifact. If the direct dependency that pulls it in has a release with the safe version, upgrade that. Otherwise, declare the safe version in your POM's dependencyManagement section; Maven then uses it for every transitive occurrence. Add an Enforcer bannedDependencies rule so the vulnerable range fails the build if it returns.

How do I force a transitive dependency version in Gradle?

Add a dependency constraint with a because reason. Because Gradle picks the highest requested version by default, a constraint with the safe version raises it. If you need to hold a version lower than something else requests, use strictly in the constraint's version block. Lock the result with dependency locking.

Does upgrading a library fix a deserialization vulnerability?

It fixes that library's gadget or binder bug. It does not stop your application from deserializing untrusted data, so the next gadget found in another library is just as dangerous. Add an ObjectInputFilter allowlist for native serialization, and use safe constructors or concrete types for YAML and JSON binders.

How do I fix XXE in Java?

Reject DOCTYPE declarations on every XML parser factory you create: the disallow-doctype-decl feature for DOM and SAX, SUPPORT_DTD false for StAX, and empty ACCESS_EXTERNAL_DTD values for TransformerFactory and SchemaFactory. Do not rely on setExpandEntityReferences(false). Then check your classpath for old XML parser jars that might be loaded instead of the JDK's parser.

Should I use enforcedPlatform in Gradle to fix vulnerabilities?

Only in an application, and only when a normal platform or a constraint will not do. enforcedPlatform overrides every other version in the graph, including higher ones, so it can downgrade a dependency that another library needs. Never use it in a library you publish.

Do Maven version ranges help with security patches?

They let a build pick up new patch releases automatically, but they also make builds non-reproducible: two builds of the same commit can resolve different versions. Most teams pin exact versions and use the Versions Maven Plugin or automated pull requests to move them. Ranges are useful inside Enforcer ban rules, where they describe what must never ship.

How do I verify a Java dependency fix actually shipped?

Rerun the dependency report for the configuration you ship, then inspect the built artifact: jar tf on the jar, war or fat jar, and a scan of the container image. Rescan with the tool that raised the finding and record the fixing commit when you close it.

Is a vulnerability in a test dependency a real risk?

It is not in your production artifact, so it ranks below runtime findings. It can still matter on build machines and developer laptops, which run test code and often hold credentials. Fix it in the normal upgrade cycle rather than as an emergency.

Java dependency fixes go through the same supply chain as everything else you ship: a pin you add today only protects you if the next rebuild resolves it, and a fix you need may not exist upstream yet. These are the two gaps the options below are designed to close.

Fix it without waiting for upstream

Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.

1. Autonomous remediation: pull requests, not tickets

When a fixed version exists, Griffin, Safeguard's security model, opens a policy gated pull request that moves the affected dependency to the safe version, and your own CI runs your tests on it before anyone merges. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation categories include dependencies, licenses, security posture and findings, and each one is set separately. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Auto-merge is a separate switch and stays off by default, so your team decides what merges on its own and what still needs a human approval.

2. Gold Open Source: the safe version in seconds, free

gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds.

3. Gold Registry: start from components that are already fixed

Safeguard Gold rebuilds patched versions of open-source packages and images from source, signs each build, attaches an SBOM, SLSA provenance and VEX, and serves them from registry.safeguard.sh to your organization. Every Gold artifact is held continuously to zero exploitable critical or high CVEs and a completed license check, with signed build provenance (signed with Safeguard's key and verifiable with cosign, SLSA Build Level 3 as the design target) and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.

When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened build of a specific package. Ask which packages it covers before you plan around it.

Your situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingSafeguard policies and gates in CI
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch
You want to start clean on new projectsPull hardened components and images from Gold Registry

One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.

See how Safeguard would help

The demo below is a real Safeguard CLI session. It shows the assistant reading a dependency finding and its advisory, editing a manifest to the fixed version, and running install and tests, asking before any command that changes something. The session uses an npm project, but the same flow applies to a pom.xml or build.gradle: ask it to fix a Java finding and review the pin or constraint it proposes against the patterns in this post.

Click through it at your own pace, or open it full screen.

This demo sets up the Safeguard local runner on your own machine, approves it with a device code and then scans from the terminal. For a Java team it is a way to scan a multi module build where the code lives, before anything is pushed.

Click through it at your own pace, or open it full screen.

The last demo connects Claude to the Safeguard MCP server with OAuth and chooses which tools Claude can use. Once connected, you can ask about the findings and dependency paths in your Java projects from the assistant you already use.

Click through it at your own pace, or open it full screen.

To try it on your own code, download Safeguard and follow the CLI guide.

Quick check

Your pom.xml declares SnakeYAML 2.2 in dependencyManagement, but a library you depend on asks for 1.33. Which version does Maven resolve?

2.2. dependencyManagement sets the version Maven uses whenever the artifact appears transitively, so it overrides the 1.33 request. Confirm it with mvn dependency:tree -Dincludes=org.yaml:snakeyaml.

You upgraded the XML library flagged by your scanner. Is your XXE finding in your own parsing code now fixed?

Not necessarily. Most Java XXE comes from parser defaults, not library bugs. You still need to disable DOCTYPE declarations or external entities on every parser factory your code creates, and test that a document with a DOCTYPE is rejected.

Sources

Last reviewed: 28 October 2026.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

We email you a link to confirm, and only then add you to the list. Unsubscribe in one click. Privacy

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.