diff --git a/src/it/MINSTALL-52/verify.groovy b/src/it/MINSTALL-52/verify.groovy index 617b5ae..4f28e28 100644 --- a/src/it/MINSTALL-52/verify.groovy +++ b/src/it/MINSTALL-52/verify.groovy @@ -22,5 +22,5 @@ assert new File( basedir, "../../local-repo/org/apache/maven/plugins/install/its File buildLog = new File( basedir, 'build.log' ) assert buildLog.exists() -assert buildLog.text.contains( "[DEBUG] Loading META-INF/maven/org.apache.maven.plugins.install.its/minstall52/pom.xml" ) -assert buildLog.text.contains( "[DEBUG] Using JAR embedded POM as pomFile" ) +assert buildLog.text.contains( "[INFO] Loading META-INF/maven/org.apache.maven.plugins.install.its/minstall52/pom.xml from" ) +assert buildLog.text.contains( "[INFO] Using JAR embedded POM as pomFile" ) diff --git a/src/main/java/org/apache/maven/plugins/install/InstallFileMojo.java b/src/main/java/org/apache/maven/plugins/install/InstallFileMojo.java index 834c8a7..95e154b 100644 --- a/src/main/java/org/apache/maven/plugins/install/InstallFileMojo.java +++ b/src/main/java/org/apache/maven/plugins/install/InstallFileMojo.java @@ -23,6 +23,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.Writer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -30,7 +32,9 @@ import java.util.List; import java.util.jar.JarEntry; import java.util.jar.JarFile; +import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.apache.maven.api.Artifact; import org.apache.maven.api.ProducedArtifact; @@ -57,6 +61,9 @@ public class InstallFileMojo implements org.apache.maven.api.plugin.Mojo { private static final String TAR = "tar."; private static final String ILLEGAL_VERSION_CHARS = "\\/:\"<>|?*[](){},"; + /** The {@code encoding} pseudo-attribute of an XML declaration. */ + private static final Pattern ENCODING_PSEUDO_ATTR = Pattern.compile("encoding\\s*=\\s*[\"']([^\"']+)[\"']"); + @Inject private Log log; @@ -174,14 +181,25 @@ public void execute() { deployedPom = pomFile; processModel(readModel(deployedPom)); } else { - if (!Boolean.TRUE.equals(generatePom)) { + if (Boolean.TRUE.equals(generatePom)) { + deployedPom = null; + } else if (groupId != null && artifactId != null && version != null && packaging != null) { + // The operator supplied the complete coordinates: do not let metadata embedded inside the + // (potentially untrusted) file silently become the authoritative POM for those coordinates. + // A minimal POM is generated below instead; use -DpomFile to install a curated POM. + log.info("Ignoring any POM embedded in " + file.getFileName() + + ": complete coordinates were supplied, so a minimal POM will be generated instead." + + " Use -DpomFile to install a specific POM."); + deployedPom = null; + } else { + // Coordinates that were supplied are cross-checked against the embedded POM inside + // readingPomFromJarFile(): a mismatch fails the build rather than installing the + // embedded POM verbatim at operator-chosen coordinates. temporaryPom = readingPomFromJarFile(); deployedPom = temporaryPom; if (deployedPom != null) { - log.debug("Using JAR embedded POM as pomFile"); + log.info("Using JAR embedded POM as pomFile"); } - } else { - deployedPom = null; } } @@ -190,18 +208,36 @@ public void execute() { + "'version' and 'packaging' are required."); } - if (!isValidId(groupId) || !isValidId(artifactId) || !isValidVersion(version)) { - throw new MojoException("The artifact information is not valid: uses invalid characters."); + if (!isValidGroupId(groupId) || !isValidId(artifactId) || !isValidId(packaging) || !isValidVersion(version)) { + throw new MojoException( + "The artifact information is not valid: uses invalid characters or empty/dot-only values."); } boolean isFilePom = classifier == null && "pom".equals(packaging); ProducedArtifact artifact = session.createProducedArtifact( groupId, artifactId, version, classifier, isFilePom ? "pom" : getExtension(file), packaging); - if (file.equals(getLocalRepositoryFile(artifact))) { + Path localRepositoryFile = getLocalRepositoryFile(artifact); + if (file.equals(localRepositoryFile)) { throw new MojoException("Cannot install artifact. " + "Artifact is already in the local repository.\n\nFile in question is: " + file + "\n"); } + if (Files.exists(localRepositoryFile) && contentDiffers(localRepositoryFile, file)) { + log.warn("The local repository already contains " + groupId + ":" + artifactId + ":" + version + " at " + + localRepositoryFile + " with different content: it will be overwritten by " + file); + } + + // Defense in depth: however the coordinates were obtained, the composed layout path must stay + // inside the local repository. + Path repositoryRoot = + session.getLocalRepository().getPath().toAbsolutePath().normalize(); + Path resolvedInstallPath = + session.getPathForLocalArtifact(artifact).toAbsolutePath().normalize(); + if (!resolvedInstallPath.startsWith(repositoryRoot)) { + throw new MojoException("The artifact coordinates " + groupId + ":" + artifactId + ":" + version + + " resolve to a path outside the local repository: " + resolvedInstallPath + + " is not under " + repositoryRoot); + } ArtifactManager artifactManager = session.getService(ArtifactManager.class); artifactManager.setPath(artifact, file); @@ -264,12 +300,19 @@ private Path readingPomFromJarFile() { Pattern pomEntry = Pattern.compile("META-INF/maven/.*/pom\\.xml"); try { try (JarFile jarFile = new JarFile(file.toFile())) { - JarEntry entry = jarFile.stream() + List pomEntries = jarFile.stream() .filter(e -> pomEntry.matcher(e.getName()).matches()) - .findFirst() - .orElse(null); + .collect(Collectors.toList()); + if (pomEntries.size() > 1) { + throw new MojoException("Found " + pomEntries.size() + " POM entries in " + file.getFileName() + + ": " + + pomEntries.stream().map(JarEntry::getName).collect(Collectors.joining(", ")) + + ". Cannot decide which one to trust: use -DpomFile or supply explicit" + + " -DgroupId/-DartifactId/-Dversion/-Dpackaging instead."); + } + JarEntry entry = pomEntries.isEmpty() ? null : pomEntries.get(0); if (entry != null) { - log.debug("Loading " + entry.getName()); + log.info("Loading " + entry.getName() + " from " + file.getFileName()); try (InputStream pomInputStream = jarFile.getInputStream(entry)) { String base = file.getFileName().toString(); @@ -280,7 +323,13 @@ private Path readingPomFromJarFile() { Files.copy(pomInputStream, pomFile, StandardCopyOption.REPLACE_EXISTING); - processModel(readModel(pomFile)); + rejectDoctype(pomFile, entry.getName()); + Model model = readModel(pomFile); + validateEmbeddedPomEntryPath(entry.getName(), model); + crossCheckOperatorCoordinates(entry.getName(), model); + processModel(model); + log.info("Using coordinates " + groupId + ":" + artifactId + ":" + version + ":" + packaging + + " from JAR embedded POM " + entry.getName()); return pomFile; } @@ -294,6 +343,233 @@ private Path readingPomFromJarFile() { return null; } + /** + * Defense-in-depth pre-screen for the JAR embedded POM: the entry is wire-origin, potentially + * attacker-authored XML, and it is handed to the session's {@link ModelXmlFactory} whose DTD and + * external-entity posture this plugin can neither configure nor guarantee across core versions. + * A valid POM never carries a DOCTYPE declaration, so any embedded POM containing one is rejected + * before it reaches the parser. The scan is encoding-aware: the bytes are decoded with the same + * charset an XML parser is required to autodetect (BOM / first-bytes / encoding pseudo-attribute, + * XML 1.0 Appendix F), so a UTF-16 or UTF-32 document cannot smuggle a NUL-interleaved DOCTYPE past + * a naive single-byte scan. The match is case-sensitive because the XML spec only recognizes the + * literal {@code null + * @param entryName the name of the matched JAR entry, must not be null + * @throws MojoException if the content contains a DOCTYPE declaration or cannot be read or decoded + */ + private void rejectDoctype(Path embeddedPom, String entryName) throws MojoException { + String content; + try { + byte[] bytes = Files.readAllBytes(embeddedPom); + content = new String(bytes, detectXmlCharset(bytes, entryName)); + } catch (IOException e) { + throw new MojoException("Error reading embedded POM " + embeddedPom, e); + } + if (content.contains("null + * @param entryName the name of the matched JAR entry, for error messages + * @return the detected charset, never null + * @throws MojoException if the detected/declared encoding is unsupported by this JVM + */ + private Charset detectXmlCharset(byte[] bytes, String entryName) throws MojoException { + if (bytes.length >= 4) { + int b0 = bytes[0] & 0xFF; + int b1 = bytes[1] & 0xFF; + int b2 = bytes[2] & 0xFF; + int b3 = bytes[3] & 0xFF; + // 4-byte BOMs and BOM-less ""); + String ebcdicDecl = ebcdicDeclEnd >= 0 ? ebcdicPrefix.substring(0, ebcdicDeclEnd) : ebcdicPrefix; + Matcher ebcdicMatcher = ENCODING_PSEUDO_ATTR.matcher(ebcdicDecl); + if (ebcdicMatcher.find()) { + return charsetOrFail(ebcdicMatcher.group(1), entryName); + } + throw new MojoException("The POM embedded in " + file.getFileName() + " (" + entryName + + ") is EBCDIC-encoded but its XML declaration names no encoding, so the exact" + + " code page cannot be determined and it cannot be screened for a DOCTYPE" + + " declaration. Use -DpomFile or supply explicit coordinates instead."); + } + } + if (bytes.length >= 2) { + int b0 = bytes[0] & 0xFF; + int b1 = bytes[1] & 0xFF; + if (b0 == 0xFE && b1 == 0xFF) { + return StandardCharsets.UTF_16BE; + } + if (b0 == 0xFF && b1 == 0xFE) { + return StandardCharsets.UTF_16LE; + } + } + // ASCII-compatible family (with or without a UTF-8 BOM): honor a declared encoding if the + // document starts with an XML declaration, else the XML default of UTF-8 applies + int offset = + bytes.length >= 3 && (bytes[0] & 0xFF) == 0xEF && (bytes[1] & 0xFF) == 0xBB && (bytes[2] & 0xFF) == 0xBF + ? 3 + : 0; + String prefix = new String(bytes, offset, Math.min(bytes.length - offset, 1024), StandardCharsets.ISO_8859_1); + if (prefix.startsWith(""); + if (declEnd < 0) { + // The declaration does not close within the sniffed prefix. Declaration whitespace is + // unbounded, so an encoding pseudo-attribute may legally sit past it (e.g. ''); defaulting to UTF-8 here would + // screen in the wrong charset while a declaration-honoring parser decodes the body in + // the declared one. Fail closed, mirroring the EBCDIC branch. + throw new MojoException("The POM embedded in " + file.getFileName() + " (" + entryName + + ") has an XML declaration that does not terminate within the sniffed prefix, so" + + " its declared encoding cannot be determined and it cannot be screened for a" + + " DOCTYPE declaration. Use -DpomFile or supply explicit coordinates instead."); + } + Matcher matcher = ENCODING_PSEUDO_ATTR.matcher(prefix.substring(0, declEnd)); + if (matcher.find()) { + return charsetOrFail(matcher.group(1), entryName); + } + } + return StandardCharsets.UTF_8; + } + + /** + * Resolves a detected or declared encoding name, failing closed if this JVM cannot decode it. + */ + private Charset charsetOrFail(String name, String entryName) throws MojoException { + try { + return Charset.forName(name); + } catch (IllegalArgumentException e) { // IllegalCharsetNameException, UnsupportedCharsetException + throw new MojoException("The POM embedded in " + file.getFileName() + " (" + entryName + + ") uses an encoding this JVM cannot decode ('" + name + "'), so it cannot be screened" + + " for a DOCTYPE declaration. Use -DpomFile or supply explicit coordinates instead."); + } + } + + /** + * Verifies that the {@code /} components of the matched + * {@code META-INF/maven///pom.xml} entry path agree with the effective coordinates + * declared by the embedded POM itself. A mismatch means the archive self-reports two different identities, + * so its metadata cannot be trusted to choose the install coordinates. + * + * @param entryName the name of the matched JAR entry, must not be null + * @param model the model parsed from that entry, must not be null + * @throws MojoException if the entry path is not of the expected shape or does not match the model + */ + private void validateEmbeddedPomEntryPath(String entryName, Model model) throws MojoException { + String[] segments = entryName.split("/"); + // expected shape: META-INF/maven///pom.xml + if (segments.length != 5) { + throw new MojoException("Unexpected embedded POM entry path '" + entryName + "' in " + + file.getFileName() + ": expected META-INF/maven///pom.xml." + + " Use -DpomFile or supply explicit coordinates instead."); + } + String entryGroupId = segments[2]; + String entryArtifactId = segments[3]; + Parent parent = model.getParent(); + String modelGroupId = + model.getGroupId() != null ? model.getGroupId() : (parent != null ? parent.getGroupId() : null); + String modelArtifactId = model.getArtifactId(); + if (!entryGroupId.equals(modelGroupId) || !entryArtifactId.equals(modelArtifactId)) { + throw new MojoException("The POM embedded in " + file.getFileName() + " declares " + modelGroupId + ":" + + modelArtifactId + " but is packaged under entry path '" + entryName + "' (" + entryGroupId + + ":" + entryArtifactId + "). Refusing to adopt coordinates from inconsistent embedded" + + " metadata: use -DpomFile or supply explicit -DgroupId/-DartifactId/-Dversion/-Dpackaging."); + } + } + + /** + * Cross-checks every operator-supplied coordinate (groupId, artifactId, version) against the effective + * values declared by the (potentially untrusted) embedded POM and fails on any mismatch. Operator values + * may override embedded metadata only through the generated-POM path (all four coordinates supplied, or + * {@code -DgeneratePom=true}) or through an explicit {@code -DpomFile}: on this path the embedded POM + * itself is what gets installed at the final coordinates, so letting a partially-specified command line + * (e.g. groupId/artifactId/version without packaging) coexist with a foreign embedded POM would install + * an attacker-authored POM verbatim at operator-chosen coordinates. + * + * @param entryName the name of the matched JAR entry, must not be null + * @param model the model parsed from that entry, must not be null + * @throws MojoException if an operator-supplied coordinate disagrees with the embedded POM + */ + private void crossCheckOperatorCoordinates(String entryName, Model model) throws MojoException { + Parent parent = model.getParent(); + String modelGroupId = + model.getGroupId() != null ? model.getGroupId() : (parent != null ? parent.getGroupId() : null); + String modelArtifactId = model.getArtifactId(); + String modelVersion = + model.getVersion() != null ? model.getVersion() : (parent != null ? parent.getVersion() : null); + List mismatches = new ArrayList<>(); + if (groupId != null && !groupId.equals(modelGroupId)) { + mismatches.add("groupId: supplied '" + groupId + "' but embedded POM declares '" + modelGroupId + "'"); + } + if (artifactId != null && !artifactId.equals(modelArtifactId)) { + mismatches.add( + "artifactId: supplied '" + artifactId + "' but embedded POM declares '" + modelArtifactId + "'"); + } + if (version != null && !version.equals(modelVersion)) { + mismatches.add("version: supplied '" + version + "' but embedded POM declares '" + modelVersion + "'"); + } + if (!mismatches.isEmpty()) { + throw new MojoException("The POM embedded in " + file.getFileName() + " (" + entryName + + ") does not match the supplied coordinates: " + String.join("; ", mismatches) + + ". Refusing to install a mismatching embedded POM at operator-chosen coordinates:" + + " use -DpomFile, supply all of -DgroupId/-DartifactId/-Dversion/-Dpackaging, or pass" + + " -DgeneratePom=true."); + } + } + + /** + * Returns {@code true} if the two files exist with different content, or cannot be compared + * (fail-closed for warning purposes). + */ + private boolean contentDiffers(Path existing, Path candidate) { + try { + return Files.mismatch(existing, candidate) != -1; + } catch (IOException e) { + return true; + } + } + /** * Parses a POM. * @@ -401,12 +677,15 @@ private String getExtension(final Path file) { } /** - * Returns {@code true} if passed in string is "valid Maven ID" (groupId or artifactId). + * Returns {@code true} if passed in string is "valid Maven ID" (artifactId or packaging): non-empty, + * not consisting solely of {@code '.'} characters (so it can never form a {@code .}/{@code ..} path + * segment in the local repository layout), and using only allowed characters. */ private boolean isValidId(String id) { - if (id == null) { + if (id == null || id.isEmpty()) { return false; } + boolean seenNonDot = false; for (int i = 0; i < id.length(); i++) { char c = id.charAt(i); if (!(c >= 'a' && c <= 'z' @@ -417,22 +696,42 @@ private boolean isValidId(String id) { || c == '.')) { return false; } + if (c != '.') { + seenNonDot = true; + } } - return true; + return seenNonDot; } /** - * Returns {@code true} if passed in string is "valid Maven (simple. non range, expression, etc) version". + * Returns {@code true} if passed in string is a valid Maven groupId: a valid ID whose dot-separated + * segments are all non-empty. Leading, trailing or consecutive dots would produce empty (or, on some + * local-repository managers, absolute) path segments after the dots-to-directories transform of the + * local repository layout, escaping the coordinate's directory. + */ + private boolean isValidGroupId(String groupId) { + return isValidId(groupId) && !groupId.startsWith(".") && !groupId.endsWith(".") && !groupId.contains(".."); + } + + /** + * Returns {@code true} if passed in string is "valid Maven (simple. non range, expression, etc) version": + * non-empty, not consisting solely of {@code '.'} characters (so it can never form a {@code .}/{@code ..} + * path segment in the local repository layout), and free of illegal characters. */ private boolean isValidVersion(String version) { - if (version == null) { + if (version == null || version.isEmpty()) { return false; } + boolean seenNonDot = false; for (int i = version.length() - 1; i >= 0; i--) { - if (ILLEGAL_VERSION_CHARS.indexOf(version.charAt(i)) >= 0) { + char c = version.charAt(i); + if (ILLEGAL_VERSION_CHARS.indexOf(c) >= 0) { return false; } + if (c != '.') { + seenNonDot = true; + } } - return true; + return seenNonDot; } } diff --git a/src/test/java/org/apache/maven/plugins/install/InstallFileMojoTest.java b/src/test/java/org/apache/maven/plugins/install/InstallFileMojoTest.java index 6ce89f9..d58ac97 100644 --- a/src/test/java/org/apache/maven/plugins/install/InstallFileMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/install/InstallFileMojoTest.java @@ -19,6 +19,7 @@ package org.apache.maven.plugins.install; import java.io.File; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -28,6 +29,8 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; import org.apache.maven.api.Artifact; import org.apache.maven.api.LocalRepository; @@ -55,6 +58,7 @@ import static org.apache.maven.api.plugin.testing.MojoExtension.getBasedir; import static org.apache.maven.api.plugin.testing.MojoExtension.getVariableValueFromObject; +import static org.apache.maven.api.plugin.testing.MojoExtension.setVariableValueToObject; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -297,6 +301,355 @@ void installFile(InstallFileMojo mojo) throws Exception { request.getSession().getLocalRepository().getPath().toString().replace(File.separator, "/")); } + @Test + @InjectMojo(goal = "install-file") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void adoptsCoordinatesFromConsistentEmbeddedPom(InstallFileMojo mojo) throws Exception { + assertNotNull(mojo); + Path jar = createJarWithEntries( + pomXml("org.example", "embedded-lib", "1.0"), "META-INF/maven/org.example/embedded-lib/pom.xml"); + setVariableValueToObject(mojo, "file", jar); + + ArtifactInstallerRequest request = execute(mojo); + + assertNotNull(request); + assertEquals("org.example", getVariableValueFromObject(mojo, "groupId")); + assertEquals("embedded-lib", getVariableValueFromObject(mojo, "artifactId")); + assertEquals("1.0", getVariableValueFromObject(mojo, "version")); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void rejectsEmbeddedPomWithMismatchedEntryPath(InstallFileMojo mojo) throws Exception { + assertNotNull(mojo); + // decoy entry path does not match the coordinates the embedded POM declares + Path jar = createJarWithEntries( + pomXml("org.apache.maven.plugins", "maven-clean-plugin", "3.4.0"), + "META-INF/maven/org.evil/decoy/pom.xml"); + setVariableValueToObject(mojo, "file", jar); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("entry path"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void rejectsJarWithMultipleEmbeddedPoms(InstallFileMojo mojo) throws Exception { + assertNotNull(mojo); + Path jar = createJarWithEntries( + pomXml("org.example", "embedded-lib", "1.0"), + "META-INF/maven/org.example/embedded-lib/pom.xml", + "META-INF/maven/org.other/other-lib/pom.xml"); + setVariableValueToObject(mojo, "file", jar); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("POM entries"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter(name = "groupId", value = "org.apache.maven.test") + @MojoParameter(name = "artifactId", value = "maven-install-file-test") + @MojoParameter(name = "version", value = "1.0-SNAPSHOT") + @MojoParameter(name = "packaging", value = "jar!") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void invalidPackagingRejected(InstallFileMojo mojo) { + assertNotNull(mojo); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("not valid"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter(name = "groupId", value = "org.apache.maven.test") + @MojoParameter(name = "artifactId", value = "maven-install-file-test") + @MojoParameter(name = "version", value = "1.0-SNAPSHOT") + @MojoParameter(name = "packaging", value = "jar") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void embeddedPomIgnoredWhenFullCoordinatesSupplied(InstallFileMojo mojo) throws Exception { + assertNotNull(mojo); + assignValuesForParameter(mojo); + // hostile embedded POM carrying a foreign GAV and an injected dependency + String evilPom = "" + "4.0.0" + + "com.evil" + + "injected" + + "9.9" + + "jar" + + "" + + "com.evilbackdoor1.0" + + "" + + ""; + Path jar = createJarWithEntries(evilPom, "META-INF/maven/com.evil/injected/pom.xml"); + setVariableValueToObject(mojo, "file", jar); + + AtomicReference model = new AtomicReference<>(); + ArtifactInstallerRequest request = execute(mojo, air -> model.set(readModel(getArtifact(null, "pom")))); + + assertNotNull(request); + // the installed POM is the generated minimal POM at the CLI coordinates, not the embedded one + assertEquals("org.apache.maven.test", model.get().getGroupId()); + assertEquals("maven-install-file-test", model.get().getArtifactId()); + assertEquals("1.0-SNAPSHOT", model.get().getVersion()); + assertTrue(model.get().getDependencies().isEmpty()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter(name = "groupId", value = "org.apache.maven.test") + @MojoParameter(name = "artifactId", value = "maven-install-file-test") + @MojoParameter(name = "version", value = "1.0-SNAPSHOT") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void mismatchedEmbeddedPomRejectedWhenPackagingOmitted(InstallFileMojo mojo) throws Exception { + assertNotNull(mojo); + // packaging omitted, so the embedded POM is consulted; the attacker POM is internally + // consistent (entry path matches its own declared GAV) but disagrees with the supplied + // coordinates — it must not be installed verbatim at those coordinates + Path jar = + createJarWithEntries(pomXml("com.evil", "injected", "9.9"), "META-INF/maven/com.evil/injected/pom.xml"); + setVariableValueToObject(mojo, "file", jar); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("does not match the supplied coordinates"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter(name = "groupId", value = "org.apache.maven.test") + @MojoParameter(name = "artifactId", value = "maven-install-file-test") + @MojoParameter(name = "version", value = "1.0-SNAPSHOT") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void matchingEmbeddedPomAcceptedWhenPackagingOmitted(InstallFileMojo mojo) throws Exception { + assertNotNull(mojo); + // packaging omitted; the embedded POM agrees with every supplied coordinate, so it may be + // used and contribute the missing packaging + Path jar = createJarWithEntries( + pomXml("org.apache.maven.test", "maven-install-file-test", "1.0-SNAPSHOT"), + "META-INF/maven/org.apache.maven.test/maven-install-file-test/pom.xml"); + setVariableValueToObject(mojo, "file", jar); + + ArtifactInstallerRequest request = execute(mojo); + + assertNotNull(request); + assertEquals("jar", getVariableValueFromObject(mojo, "packaging")); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void rejectsEmbeddedPomWithDoctype(InstallFileMojo mojo) throws Exception { + assertNotNull(mojo); + String xxePom = "" + + "]>" + + pomXml("org.example", "embedded-lib", "1.0"); + Path jar = createJarWithEntries(xxePom, "META-INF/maven/org.example/embedded-lib/pom.xml"); + setVariableValueToObject(mojo, "file", jar); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("DOCTYPE"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void rejectsEmbeddedPomWithDoctypeUtf16Le(InstallFileMojo mojo) throws Exception { + assertNotNull(mojo); + String xxePom = "" + + "]>" + + pomXml("org.example", "embedded-lib", "1.0"); + // BOM-prefixed UTF-16LE: interleaved NUL bytes defeat a single-byte substring scan + byte[] bytes = ("\uFEFF" + xxePom).getBytes(StandardCharsets.UTF_16LE); + Path jar = createJarWithEntryBytes(bytes, "META-INF/maven/org.example/embedded-lib/pom.xml"); + setVariableValueToObject(mojo, "file", jar); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("DOCTYPE"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void rejectsEmbeddedPomWithDoctypeUtf16Be(InstallFileMojo mojo) throws Exception { + assertNotNull(mojo); + String xxePom = "" + + "]>" + + pomXml("org.example", "embedded-lib", "1.0"); + // BOM-prefixed UTF-16BE + byte[] bytes = ("\uFEFF" + xxePom).getBytes(StandardCharsets.UTF_16BE); + Path jar = createJarWithEntryBytes(bytes, "META-INF/maven/org.example/embedded-lib/pom.xml"); + setVariableValueToObject(mojo, "file", jar); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("DOCTYPE"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void rejectsEmbeddedPomWithDoctypeEbcdicIbm500(InstallFileMojo mojo) throws Exception { + assertNotNull(mojo); + String xxePom = "" + + "]>" + + pomXml("org.example", "embedded-lib", "1.0"); + // IBM500 and IBM037 disagree on the EBCDIC variant byte for '!': a screen that decodes the + // whole document as IBM037 sees "<|DOCTYPE" and misses the DTD. The declared code page must win. + byte[] bytes = xxePom.getBytes(java.nio.charset.Charset.forName("IBM500")); + Path jar = createJarWithEntryBytes(bytes, "META-INF/maven/org.example/embedded-lib/pom.xml"); + setVariableValueToObject(mojo, "file", jar); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("DOCTYPE"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void rejectsEmbeddedPomWithDeclPaddedPastSniffPrefix(InstallFileMojo mojo) throws Exception { + assertNotNull(mojo); + // Declaration whitespace is unbounded: pad the ASCII declaration so 'encoding="IBM037"' and + // '?>' sit past the 1024-byte sniff prefix, then append an IBM037 body carrying the DOCTYPE. + // A fail-open UTF-8 default screens the wrong charset and misses the DTD that a + // declaration-honoring parser would decode; the screen must fail closed instead. + StringBuilder decl = new StringBuilder(""); + String body = "]>" + + pomXml("org.example", "embedded-lib", "1.0"); + byte[] declBytes = decl.toString().getBytes(StandardCharsets.US_ASCII); + byte[] bodyBytes = body.getBytes(java.nio.charset.Charset.forName("IBM037")); + byte[] bytes = new byte[declBytes.length + bodyBytes.length]; + System.arraycopy(declBytes, 0, bytes, 0, declBytes.length); + System.arraycopy(bodyBytes, 0, bytes, declBytes.length, bodyBytes.length); + Path jar = createJarWithEntryBytes(bytes, "META-INF/maven/org.example/embedded-lib/pom.xml"); + setVariableValueToObject(mojo, "file", jar); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("DOCTYPE"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter(name = "groupId", value = ".tmp.evil") + @MojoParameter(name = "artifactId", value = "maven-install-file-test") + @MojoParameter(name = "version", value = "1.0-SNAPSHOT") + @MojoParameter(name = "packaging", value = "jar") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void groupIdWithLeadingDotRejected(InstallFileMojo mojo) { + assertNotNull(mojo); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("not valid"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter(name = "groupId", value = "org..evil") + @MojoParameter(name = "artifactId", value = "maven-install-file-test") + @MojoParameter(name = "version", value = "1.0-SNAPSHOT") + @MojoParameter(name = "packaging", value = "jar") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void groupIdWithConsecutiveDotsRejected(InstallFileMojo mojo) { + assertNotNull(mojo); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("not valid"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter(name = "groupId", value = "org.apache.maven.test") + @MojoParameter(name = "artifactId", value = "..") + @MojoParameter(name = "version", value = "1.0-SNAPSHOT") + @MojoParameter(name = "packaging", value = "jar") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void dotDotArtifactIdRejected(InstallFileMojo mojo) { + assertNotNull(mojo); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("not valid"), e.getMessage()); + } + + @Test + @InjectMojo(goal = "install-file") + @MojoParameter(name = "groupId", value = "org.apache.maven.test") + @MojoParameter(name = "artifactId", value = "maven-install-file-test") + @MojoParameter(name = "version", value = "..") + @MojoParameter(name = "packaging", value = "jar") + @MojoParameter( + name = "file", + value = "${project.basedir}/target/test-classes/unit/maven-install-test-1.0-SNAPSHOT.jar") + void dotDotVersionRejected(InstallFileMojo mojo) { + assertNotNull(mojo); + + MojoException e = assertThrows(MojoException.class, mojo::execute); + assertTrue(e.getMessage().contains("not valid"), e.getMessage()); + } + + private static Path createJarWithEntries(String pomContent, String... entryNames) throws Exception { + Path jar = Files.createTempFile("maven-install-file-test", ".jar"); + try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(jar))) { + for (String entryName : entryNames) { + jos.putNextEntry(new JarEntry(entryName)); + jos.write(pomContent.getBytes(StandardCharsets.UTF_8)); + jos.closeEntry(); + } + } + return jar; + } + + private static Path createJarWithEntryBytes(byte[] pomContent, String entryName) throws Exception { + Path jar = Files.createTempFile("maven-install-file-test", ".jar"); + try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(jar))) { + jos.putNextEntry(new JarEntry(entryName)); + jos.write(pomContent); + jos.closeEntry(); + } + return jar; + } + + private static String pomXml(String groupId, String artifactId, String version) { + return "" + "4.0.0" + + "" + groupId + "" + + "" + artifactId + "" + + "" + version + "" + + "jar" + + ""; + } + private void assignValuesForParameter(Object obj) throws Exception { this.groupId = (String) getVariableValueFromObject(obj, "groupId"); this.artifactId = (String) getVariableValueFromObject(obj, "artifactId");