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
12 changes: 12 additions & 0 deletions src/java/containers/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ func (r *Registry) RegisterStandardContainers() {
r.Register(NewJavaMainContainer(r.context))
}

// JavaExecCommand builds a start command of the form:
//
// eval "exec $JAVA_HOME/bin/java $JAVA_OPTS <javaArgs>"
//
// Wrapping the argument to eval in double quotes prevents bash from
// glob-expanding or word-splitting $JAVA_OPTS before eval sees it.
// eval then re-parses the string, honouring any embedded quotes in $JAVA_OPTS.
// javaArgs must be buildpack-generated command fragments (not untrusted input).
func JavaExecCommand(javaArgs string) string {
return `eval "exec $JAVA_HOME/bin/java $JAVA_OPTS ` + javaArgs + `"`
}

// This script is used to process the CLASSPATH assembled from various framework scripts sourced from profile.d
// to further create symlinks to the corresponding framework dependencies in WEB-INF/lib, BOOT-INF/lib and where ever
// needed thus they are available for application classloading
Expand Down
9 changes: 3 additions & 6 deletions src/java/containers/java_main.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,13 +292,11 @@ func (j *JavaMainContainer) Release() (string, error) {
// JBP_CONFIG_JAVA_MAIN java_main_class takes precedence over manifest Main-Class.
// Use classpath mode so the configured class is actually invoked (not the manifest's).
if cfg.JavaMainClass != "" {
return fmt.Sprintf("eval exec $JAVA_HOME/bin/java $JAVA_OPTS -cp ${CLASSPATH}${CONTAINER_SECURITY_PROVIDER:+:$CONTAINER_SECURITY_PROVIDER} %s%s", cfg.JavaMainClass, args), nil
return JavaExecCommand(fmt.Sprintf("-cp ${CLASSPATH}${CONTAINER_SECURITY_PROVIDER:+:$CONTAINER_SECURITY_PROVIDER} %s%s", cfg.JavaMainClass, args)), nil
}

if j.jarFile != "" {
// JAR has its own Main-Class in the manifest — java -jar handles it
// Use eval to properly handle backslash-escaped values in $JAVA_OPTS (Ruby buildpack parity)
return fmt.Sprintf("eval exec $JAVA_HOME/bin/java $JAVA_OPTS -jar %s%s", j.jarFile, args), nil
return JavaExecCommand(fmt.Sprintf("-jar %s%s", j.jarFile, args)), nil
}

// Classpath mode: need an explicit main class
Expand All @@ -311,6 +309,5 @@ func (j *JavaMainContainer) Release() (string, error) {
j.context.Log.Debug("Main Class %s found in JAVA_MAIN_CLASS", mainClass)
}

// Use eval to properly handle backslash-escaped values in $JAVA_OPTS (Ruby buildpack parity)
return fmt.Sprintf("eval exec $JAVA_HOME/bin/java $JAVA_OPTS -cp ${CLASSPATH}${CONTAINER_SECURITY_PROVIDER:+:$CONTAINER_SECURITY_PROVIDER} %s%s", mainClass, args), nil
return JavaExecCommand(fmt.Sprintf("-cp ${CLASSPATH}${CONTAINER_SECURITY_PROVIDER:+:$CONTAINER_SECURITY_PROVIDER} %s%s", mainClass, args)), nil
}
61 changes: 61 additions & 0 deletions src/java/containers/java_main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ var _ = Describe("Java Main Container", func() {
})

Describe("buildClasspath", func() {
expectQuotedEval := func(cmd string) {
Expect(cmd).To(MatchRegexp(`eval "exec .*\$JAVA_OPTS`))
}

Context("with JARs in root and lib/", func() {
BeforeEach(func() {
os.WriteFile(filepath.Join(buildDir, "app.jar"), []byte("fake"), 0644)
Expand Down Expand Up @@ -351,6 +355,63 @@ var _ = Describe("Java Main Container", func() {
Expect(cmd).To(ContainSubstring("."))
})
})

// Regression tests for issue #1301: start command must use eval "exec ... $JAVA_OPTS"
// (quoted string) so that glob chars in JAVA_OPTS are not expanded by bash before eval.
Context("with JAR file (eval quoting)", func() {
BeforeEach(func() {
Expect(createJar(
filepath.Join(buildDir, "app.jar"),
"Manifest-Version: 1.0\nMain-Class: com.example.Main\n",
)).To(Succeed())
container.Detect()
})

It("uses quoted eval to protect $JAVA_OPTS from glob expansion", func() {
cmd, err := container.Release()
Expect(err).NotTo(HaveOccurred())
expectQuotedEval(cmd)
})
})

Context("with JAVA_MAIN_CLASS env variable (eval quoting)", func() {
BeforeEach(func() {
os.Setenv("JAVA_MAIN_CLASS", "com.example.Main")
os.WriteFile(filepath.Join(buildDir, "Main.class"), []byte("fake"), 0644)
container.Detect()
})

AfterEach(func() {
os.Unsetenv("JAVA_MAIN_CLASS")
})

It("uses quoted eval to protect $JAVA_OPTS from glob expansion", func() {
cmd, err := container.Release()
Expect(err).NotTo(HaveOccurred())
expectQuotedEval(cmd)
})
})

Context("with JBP_CONFIG_JAVA_MAIN java_main_class (eval quoting)", func() {
BeforeEach(func() {
os.Setenv("JBP_CONFIG_JAVA_MAIN", "{java_main_class: com.example.Main}")
Expect(createJar(
filepath.Join(buildDir, "app.jar"),
"Manifest-Version: 1.0\nMain-Class: com.example.Main\n",
)).To(Succeed())
container.Detect()
})

AfterEach(func() {
os.Unsetenv("JBP_CONFIG_JAVA_MAIN")
})

It("uses quoted eval to protect $JAVA_OPTS from glob expansion", func() {
cmd, err := container.Release()
Expect(err).NotTo(HaveOccurred())
expectQuotedEval(cmd)
})
})
})

Describe("Finalize", func() {
Expand Down
13 changes: 4 additions & 9 deletions src/java/containers/spring_boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,21 +256,16 @@ func (s *SpringBootContainer) Release() (string, error) {
// Verify this is actually a Spring Boot application

if s.isSpringBootExplodedJar(buildDir) {
// True Spring Boot exploded JAR - use main class from manifest or fallback to JarLauncher based on spring-boot version
launcherClass := s.getLauncherClass(buildDir)
// Use eval to properly handle backslash-escaped values in $JAVA_OPTS (Ruby buildpack parity)
return fmt.Sprintf("eval exec $JAVA_HOME/bin/java $JAVA_OPTS -cp $PWD/.${CONTAINER_SECURITY_PROVIDER:+:$CONTAINER_SECURITY_PROVIDER} %s", launcherClass), nil
return JavaExecCommand(fmt.Sprintf("-cp $PWD/.${CONTAINER_SECURITY_PROVIDER:+:$CONTAINER_SECURITY_PROVIDER} %s", launcherClass)), nil
}

// Exploded JAR but NOT Spring Boot - use Main-Class from MANIFEST.MF
mainClass, err := s.readMainClassFromManifest(buildDir)
if err != nil {
s.context.Log.Debug("Could not read MANIFEST.MF: %s", err.Error())
}
if mainClass != "" {
// Use classpath from BOOT-INF/classes and BOOT-INF/lib
// Use eval to properly handle backslash-escaped values in $JAVA_OPTS (Ruby buildpack parity)
return fmt.Sprintf("eval exec $JAVA_HOME/bin/java $JAVA_OPTS -cp $HOME${CONTAINER_SECURITY_PROVIDER:+:$CONTAINER_SECURITY_PROVIDER}:$HOME/BOOT-INF/classes:$HOME/BOOT-INF/lib/* %s", mainClass), nil
return JavaExecCommand(fmt.Sprintf("-cp $HOME${CONTAINER_SECURITY_PROVIDER:+:$CONTAINER_SECURITY_PROVIDER}:$HOME/BOOT-INF/classes:$HOME/BOOT-INF/lib/* %s", mainClass)), nil
}

return "", fmt.Errorf("exploded JAR found but no Main-Class in MANIFEST.MF")
Expand All @@ -292,8 +287,8 @@ func (s *SpringBootContainer) Release() (string, error) {
jarFile = jar
}

// Use eval to properly handle backslash-escaped values in $JAVA_OPTS (Ruby buildpack parity)
cmd := fmt.Sprintf("eval exec $JAVA_HOME/bin/java $JAVA_OPTS ${CONTAINER_SECURITY_PROVIDER:+-Dloader.path=$CONTAINER_SECURITY_PROVIDER} -jar %s", jarFile)
// Use eval with quoted string to prevent glob-expansion of $JAVA_OPTS (#1301)
cmd := JavaExecCommand(fmt.Sprintf("${CONTAINER_SECURITY_PROVIDER:+-Dloader.path=$CONTAINER_SECURITY_PROVIDER} -jar %s", jarFile))
return cmd, nil
}

Expand Down
16 changes: 16 additions & 0 deletions src/java/containers/spring_boot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ var _ = Describe("Spring Boot Container", func() {
})

Describe("Release", func() {
expectQuotedEval := func(cmd string) {
Expect(cmd).To(MatchRegexp(`eval "exec .*\$JAVA_OPTS`))
}

Context("with exploded JAR (BOOT-INF)", func() {
BeforeEach(func() {
os.MkdirAll(filepath.Join(buildDir, "BOOT-INF"), 0755)
Expand All @@ -136,6 +140,12 @@ var _ = Describe("Spring Boot Container", func() {
Expect(err).NotTo(HaveOccurred())
Expect(cmd).To(ContainSubstring("JarLauncher"))
})

It("uses quoted eval to protect $JAVA_OPTS from glob expansion", func() {
cmd, err := container.Release()
Expect(err).NotTo(HaveOccurred())
expectQuotedEval(cmd)
})
})

Context("with Spring Boot JAR", func() {
Expand All @@ -151,6 +161,12 @@ var _ = Describe("Spring Boot Container", func() {
Expect(cmd).To(ContainSubstring("java"))
Expect(cmd).To(ContainSubstring("app-boot.jar"))
})

It("uses quoted eval to protect $JAVA_OPTS from glob expansion", func() {
cmd, err := container.Release()
Expect(err).NotTo(HaveOccurred())
expectQuotedEval(cmd)
})
})

Context("with no Spring Boot JAR found", func() {
Expand Down
18 changes: 13 additions & 5 deletions src/java/frameworks/java_opts_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ func CreateJavaOptsAssemblyScript(ctx *common.Context) error {

# Save original JAVA_OPTS from environment (user-provided)
# Normalize to single line: YAML block scalars (>) may introduce newlines
# xargs trims leading/trailing whitespace and collapses internal spaces
USER_JAVA_OPTS=$(echo "$JAVA_OPTS" | tr '\n' ' ' | tr -s ' ' | xargs)
# Only convert newlines to spaces — do not use xargs which strips quotes and backslashes
USER_JAVA_OPTS=$(echo "$JAVA_OPTS" | tr '\n' ' ')

# Start building new JAVA_OPTS
JAVA_OPTS=""
Expand All @@ -86,18 +86,26 @@ if [ -d "$DEPS_DIR/%s/java_opts" ]; then
# Read content and expand runtime variables
opts_content=$(cat "$opts_file")

# Expand $DEPS_DIR, $HOME, $JAVA_OPTS using bash parameter expansion.
# Expand $DEPS_DIR and $HOME using bash parameter expansion.
# sed-based substitution breaks when these values contain the sed delimiter (|),
# backslashes, ampersands, or newlines — all valid in JAVA_OPTS and paths.
opts_content="${opts_content//\$DEPS_DIR/$DEPS_DIR}"
opts_content="${opts_content//\$HOME/$HOME}"
opts_content="${opts_content//\$JAVA_OPTS/$USER_JAVA_OPTS}"


# Shield $JAVA_OPTS from eval: replace with a placeholder first,
# then substitute the actual value AFTER eval so that quotes and
# backslashes in the user-provided JAVA_OPTS are never exposed to eval.
_user_java_opts_placeholder='__JAVA_OPTS_BUILDPACK_PLACEHOLDER__'
opts_content="${opts_content//\$JAVA_OPTS/$_user_java_opts_placeholder}"

# Expand any remaining environment variables in opts content via eval.
# Note: eval executes commands, but .opts files are written by the buildpack
# at staging time and run within the container context.
# This matches how the Ruby buildpack naturally expanded variables via shell.
opts_content=$(eval "echo \"$opts_content\"")

# Now safely substitute JAVA_OPTS after eval (preserves quotes and backslashes)
opts_content="${opts_content//$_user_java_opts_placeholder/$USER_JAVA_OPTS}"

if [ -n "$opts_content" ]; then
JAVA_OPTS="$JAVA_OPTS $opts_content"
Expand Down
79 changes: 67 additions & 12 deletions src/java/frameworks/java_opts_writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
Expand Down Expand Up @@ -53,26 +54,20 @@ var _ = Describe("Java Opts Writer", func() {
os.RemoveAll(depsDir)
})

Describe("Basic options", func() {
It("writes JAVA_OPTS correctly", func() {
javaOpts := "-Xmx512M -Xms256M"
os.Setenv("JAVA_OPTS", javaOpts)

Expect(os.Getenv("JAVA_OPTS")).To(Equal(javaOpts))
})
})

Describe("CreateJavaOptsAssemblyScript", func() {
runScript := func(javaOpts string, optsFileContent string) (string, error) {
setupScript := func(javaOpts string, optsFileContent string) string {
err := frameworks.CreateJavaOptsAssemblyScript(ctx)
Expect(err).NotTo(HaveOccurred())

optsDir := filepath.Join(depsDir, "0", "java_opts")
Expect(os.MkdirAll(optsDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(optsDir, "42_agent.opts"), []byte(optsFileContent), 0644)).To(Succeed())

scriptPath := filepath.Join(depsDir, "0", "profile.d", "00_java_opts.sh")
cmd := exec.Command("bash", "-c", "source "+scriptPath+" && echo \"$JAVA_OPTS\"")
return filepath.Join(depsDir, "0", "profile.d", "00_java_opts.sh")
}

runWithEnv := func(scriptPath, javaOpts, bashExpr string) (string, error) {
cmd := exec.Command("bash", "-c", "source "+scriptPath+" && "+bashExpr)
cmd.Env = append(os.Environ(),
"JAVA_OPTS="+javaOpts,
"DEPS_DIR="+depsDir,
Expand All @@ -82,6 +77,22 @@ var _ = Describe("Java Opts Writer", func() {
return string(output), err
}

runScript := func(javaOpts string, optsFileContent string) (string, error) {
scriptPath := setupScript(javaOpts, optsFileContent)
return runWithEnv(scriptPath, javaOpts, "echo \"$JAVA_OPTS\"")
}

// runStartCommand simulates the actual JVM invocation:
// eval "exec $JAVA_HOME/bin/java $JAVA_OPTS -jar app.jar"
// Returns the argument list java would receive (one arg per line).
runStartCommand := func(javaOpts string, optsFileContent string) (string, error) {
scriptPath := setupScript(javaOpts, optsFileContent)
// Simulate: eval "exec java $JAVA_OPTS" — quoted string prevents bash glob-expansion.
// eval then re-parses the string, honouring embedded quotes in $JAVA_OPTS.
return runWithEnv(scriptPath, javaOpts,
`eval "set -- $JAVA_OPTS"; printf '%s\n' "$@"`)
}

It("handles multiline JAVA_OPTS from YAML block scalar without sed error", func() {
// Reproduce the manifest pattern:
// JAVA_OPTS: >
Expand Down Expand Up @@ -117,5 +128,49 @@ var _ = Describe("Java Opts Writer", func() {
Expect(err).NotTo(HaveOccurred(), "script failed with output: %s", output)
Expect(output).To(ContainSubstring("-Djava.security.properties=" + depsDir + "/0/security.properties"))
})

// Regression tests for issue #1301: xargs strips quotes, breaking quoted JVM args
It("preserves quoted value with spaces in JAVA_OPTS", func() {
// JAVA_OPTS='-Dfoo="bar baz"' — xargs removes the quotes from USER_JAVA_OPTS,
// so when eval exec java $JAVA_OPTS is called, -Dfoo=bar and baz become separate args
output, err := runScript(`-Dfoo="bar baz"`, "$JAVA_OPTS")
Expect(err).NotTo(HaveOccurred(), "script failed with output: %s", output)
Expect(output).To(ContainSubstring(`-Dfoo="bar baz"`))
})

It("preserves cron expression with glob characters in JAVA_OPTS", func() {
// JAVA_OPTS='-DcronSched="0 */7 * * * *"' — xargs strips quotes, then * expands via glob
// when eval exec java $JAVA_OPTS is invoked, corrupting the cron expression
output, err := runScript(`-DcronSched="0 */7 * * * *"`, "$JAVA_OPTS")
Expect(err).NotTo(HaveOccurred(), "script failed with output: %s", output)
Expect(output).To(ContainSubstring(`-DcronSched="0 */7 * * * *"`))
})

It("preserves multiple quoted args in JAVA_OPTS", func() {
// Multiple quoted values — xargs strips all quotes, each space-containing value splits
output, err := runScript(`-Dfoo="bar baz" -Dother="qux quux"`, "$JAVA_OPTS")
Expect(err).NotTo(HaveOccurred(), "script failed with output: %s", output)
Expect(output).To(ContainSubstring(`-Dfoo="bar baz"`))
Expect(output).To(ContainSubstring(`-Dother="qux quux"`))
})

It("preserves backslashes in JAVA_OPTS values", func() {
// xargs treats backslash as escape char: C:\path\to\app -> C:pathtoapp
// Affects regex patterns and any path using backslash notation
output, err := runScript(`-DregEx="[a-z]+(.*)" -Dpattern=foo\|bar`, "$JAVA_OPTS")
Expect(err).NotTo(HaveOccurred(), "script failed with output: %s", output)
Expect(output).To(ContainSubstring(`-DregEx="[a-z]+(.*)"`))
Expect(output).To(ContainSubstring(`foo\|bar`))
})

// Full invocation cycle test for issue #1301:
// Verifies that the quoted eval "exec ... $JAVA_OPTS" form delivers the correct
// argument to java — glob chars in $JAVA_OPTS are not expanded.
It("does not glob-expand * in cron expression when invoking java", func() {
output, err := runStartCommand(`-DcronSched="0 */7 * * * *"`, "$JAVA_OPTS")
Expect(err).NotTo(HaveOccurred(), "script failed with output: %s", output)
// Java receives exactly one arg: -DcronSched=0 */7 * * * *
Expect(strings.TrimSpace(output)).To(Equal("-DcronSched=0 */7 * * * *"))
})
})
})