Skip to content
Draft
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
13 changes: 13 additions & 0 deletions src/java/containers/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,19 @@ 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 may include app/config-derived values (e.g. jar paths, Main-Class,
// configured arguments), so callers must ensure it is shell-safe.
func JavaExecCommand(javaArgs string) string {
Comment thread
stokpop marked this conversation as resolved.
return `eval "exec $JAVA_HOME/bin/java $JAVA_OPTS ` + javaArgs + `"`
}
Comment thread
stokpop marked this conversation as resolved.

// 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
12 changes: 5 additions & 7 deletions src/java/containers/java_main.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,19 +286,18 @@ func (j *JavaMainContainer) Release() (string, error) {

args := ""
if cfg.Arguments != "" {
args = " " + cfg.Arguments
// Escape double quotes so they don't terminate the outer eval "..." string.
args = " " + strings.ReplaceAll(cfg.Arguments, `"`, `\"`)
}

// 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
Comment thread
stokpop marked this conversation as resolved.
}

// Classpath mode: need an explicit main class
Expand All @@ -311,6 +310,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
}
76 changes: 76 additions & 0 deletions src/java/containers/java_main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ var _ = Describe("Java Main Container", func() {
})

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

Context("with JAR file", func() {
BeforeEach(func() {
Expect(createJar(
Expand Down Expand Up @@ -278,6 +282,77 @@ var _ = Describe("Java Main Container", func() {
Expect(err).NotTo(HaveOccurred())
Expect(cmd).To(ContainSubstring("--foo=bar"))
})

It("escapes double quotes in arguments to avoid breaking the eval string", func() {
os.Setenv("JBP_CONFIG_JAVA_MAIN", `{arguments: "--spring.datasource.url=\"jdbc:h2:mem:test\""}`)
Expect(createJar(
filepath.Join(buildDir, "app.jar"),
"Manifest-Version: 1.0\nMain-Class: com.example.Main\n",
)).To(Succeed())
container.Detect()
cmd, err := container.Release()
Expect(err).NotTo(HaveOccurred())
// Double quotes in arguments must be backslash-escaped so they
// don't terminate the outer eval "..." string.
Expect(cmd).To(ContainSubstring(`--spring.datasource.url=\"jdbc:h2:mem:test\"`))
})
})

// 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)
})
})

Context("without main class or JAR", func() {
Expand Down Expand Up @@ -351,6 +426,7 @@ var _ = Describe("Java Main Container", func() {
Expect(cmd).To(ContainSubstring("."))
})
})

})

Describe("Finalize", func() {
Expand Down
4 changes: 2 additions & 2 deletions src/java/containers/play.go
Original file line number Diff line number Diff line change
Expand Up @@ -548,8 +548,8 @@ func (p *PlayContainer) Release() (string, error) {
libPath = filepath.ToSlash(relPath)
}
}
// Use eval to properly handle backslash-escaped values in $JAVA_OPTS (Ruby buildpack parity)
cmd = fmt.Sprintf("eval exec java $JAVA_OPTS -cp $HOME/%s/* play.core.server.NettyServer $HOME", libPath)
// Use quoted eval argument to prevent glob/word-splitting of $JAVA_OPTS before eval.
cmd = fmt.Sprintf(`eval "exec java $JAVA_OPTS -cp $HOME/%s/* play.core.server.NettyServer $HOME"`, libPath)
}

p.context.Log.Debug("Play Framework release command: %s", cmd)
Expand Down
4 changes: 2 additions & 2 deletions src/java/containers/play_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ var _ = Describe("Play Container", func() {
It("returns correct java command", func() {
cmd, err := container.Release()
Expect(err).NotTo(HaveOccurred())
Expect(cmd).To(ContainSubstring("java $JAVA_OPTS -cp"))
Expect(cmd).To(ContainSubstring(`eval "exec java $JAVA_OPTS -cp`))
Expect(cmd).To(ContainSubstring("play.core.server.NettyServer $HOME"))
})
})
Expand All @@ -201,7 +201,7 @@ var _ = Describe("Play Container", func() {
It("returns correct java command", func() {
cmd, err := container.Release()
Expect(err).NotTo(HaveOccurred())
Expect(cmd).To(ContainSubstring("java $JAVA_OPTS -cp"))
Expect(cmd).To(ContainSubstring(`eval "exec java $JAVA_OPTS -cp`))
Expect(cmd).To(ContainSubstring("play.core.server.NettyServer $HOME"))
})
})
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
41 changes: 31 additions & 10 deletions src/java/frameworks/java_opts_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,30 +74,51 @@ 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=$(printf '%%s' "$JAVA_OPTS" | tr '\n' ' ')

# Start building new JAVA_OPTS
JAVA_OPTS=""

# Escape replacement-special chars once; these values are loop-invariant.
_escaped_deps_dir="${DEPS_DIR//\\/\\\\}"
_escaped_deps_dir="${_escaped_deps_dir//&/\\&}"
_escaped_home="${HOME//\\/\\\\}"
_escaped_home="${_escaped_home//&/\\&}"
_user_java_opts_placeholder='__JAVA_OPTS_BUILDPACK_PLACEHOLDER__'
_escaped_user_java_opts="${USER_JAVA_OPTS//\\/\\\\}"
_escaped_user_java_opts="${_escaped_user_java_opts//&/\\&}"

if [ -d "$DEPS_DIR/%s/java_opts" ]; then
for opts_file in "$DEPS_DIR/%s/java_opts"/*.opts; do
if [ -f "$opts_file" ]; then
# Read content and expand runtime variables
opts_content=$(cat "$opts_file")

# Expand $DEPS_DIR, $HOME, $JAVA_OPTS 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}"

# Expand $DEPS_DIR and $HOME using bash parameter expansion.
# In ${var//pattern/repl}, '&' and '\' are special in replacement strings,
# so escape them first to preserve literal path contents.
opts_content="${opts_content//\$DEPS_DIR/$_escaped_deps_dir}"
opts_content="${opts_content//\$HOME/$_escaped_home}"

# 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.
opts_content="${opts_content//\$JAVA_OPTS/$_user_java_opts_placeholder}"

# Escape \ and " in opts_content so they do not break the
# eval "..." wrapper below. \ must be escaped first.
_eval_safe="${opts_content//\\/\\\\}"
_eval_safe="${_eval_safe//\"/\\\"}"

# 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\"")
opts_content=$(eval "printf '%%s' \"$_eval_safe\"")

Comment thread
stokpop marked this conversation as resolved.
# Now safely substitute JAVA_OPTS after eval (preserves quotes, backslashes, and ampersands)
opts_content="${opts_content//$_user_java_opts_placeholder/$_escaped_user_java_opts}"

if [ -n "$opts_content" ]; then
JAVA_OPTS="$JAVA_OPTS $opts_content"
Expand Down
Loading